diff --git a/.config/jest.config.js b/.config/jest.config.js index f62c30e..a95d7a4 100644 --- a/.config/jest.config.js +++ b/.config/jest.config.js @@ -3,7 +3,7 @@ module.exports = { moduleFileExtensions: ['js', 'ts'], rootDir: '..', testEnvironment: 'node', - testMatch: ['/src/**/*.test.ts'], + testMatch: ['/src/**/*.spec.ts'], transform: { '^.+\\.ts$': 'ts-jest', }, diff --git a/.github/codecov.yml b/.github/codecov.yml deleted file mode 100644 index bb33a82..0000000 --- a/.github/codecov.yml +++ /dev/null @@ -1,12 +0,0 @@ -# see https://docs.codecov.com/docs/codecovyml-reference -codecov: - require_ci_to_pass: false -comment: - layout: 'diff, flags' - behavior: default - require_changes: true -coverage: - # don't pass/fail PRs for coverage yet - status: - project: off - patch: off diff --git a/.github/scripts/log-examples.js b/.github/scripts/log-examples.js deleted file mode 100644 index e0d47c5..0000000 --- a/.github/scripts/log-examples.js +++ /dev/null @@ -1,5 +0,0 @@ -console.log('console.log test'); -console.warn('console.warn test'); -console.error('console.error test'); -process.stdout.write('stdout test'); -process.stderr.write('stderr test'); diff --git a/.github/workflows/ci_cd.yml b/.github/workflows/ci_cd.yml index 6967674..f0d60f0 100644 --- a/.github/workflows/ci_cd.yml +++ b/.github/workflows/ci_cd.yml @@ -23,568 +23,3 @@ jobs: run: npm ci - name: Run Unit Tests run: npm test - - uses: codecov/codecov-action@v3 - with: - directory: ./coverage/ - verbose: true - - ci_integration: - name: Run Integration Tests - runs-on: ubuntu-latest - steps: - - name: Checkout - uses: actions/checkout@v4 - - name: Setup Node.js - uses: actions/setup-node@v4 - with: - node-version: 20 - - name: Install dependencies - run: npm ci - - - name: happy-path - id: happy_path - uses: ./ - with: - timeout_minutes: 1 - max_attempts: 2 - command: npm -v - - uses: nick-invision/assert-action@v1 - with: - expected: true - actual: ${{ steps.happy_path.outputs.total_attempts == '1' && steps.happy_path.outputs.exit_code == '0' }} - - - name: log examples - uses: ./ - with: - command: node ./.github/scripts/log-examples.js - timeout_minutes: 1 - - - name: sad-path (error) - id: sad_path_error - uses: ./ - continue-on-error: true - with: - timeout_minutes: 1 - max_attempts: 2 - command: node -e "process.exit(1)" - - uses: nick-invision/assert-action@v1 - with: - expected: 2 - actual: ${{ steps.sad_path_error.outputs.total_attempts }} - - uses: nick-invision/assert-action@v1 - with: - expected: failure - actual: ${{ steps.sad_path_error.outcome }} - - - name: retry_on (timeout) fails early if error encountered - id: retry_on_timeout_fail - uses: ./ - continue-on-error: true - with: - timeout_minutes: 1 - max_attempts: 3 - retry_on: timeout - command: node -e "process.exit(2)" - - uses: nick-invision/assert-action@v1 - with: - expected: 1 - actual: ${{ steps.retry_on_timeout_fail.outputs.total_attempts }} - - uses: nick-invision/assert-action@v1 - with: - expected: failure - actual: ${{ steps.retry_on_timeout_fail.outcome }} - - uses: nick-invision/assert-action@v1 - with: - expected: 2 - actual: ${{ steps.retry_on_timeout_fail.outputs.exit_code }} - - - name: retry_on (error) - id: retry_on_error - uses: ./ - continue-on-error: true - with: - timeout_minutes: 1 - max_attempts: 2 - retry_on: error - command: node -e "process.exit(2)" - - uses: nick-invision/assert-action@v1 - with: - expected: 2 - actual: ${{ steps.retry_on_error.outputs.total_attempts }} - - uses: nick-invision/assert-action@v1 - with: - expected: failure - actual: ${{ steps.retry_on_error.outcome }} - - uses: nick-invision/assert-action@v1 - with: - expected: 2 - actual: ${{ steps.retry_on_error.outputs.exit_code }} - - - name: sad-path (wrong shell for OS) - id: wrong_shell - uses: ./ - continue-on-error: true - with: - timeout_minutes: 1 - max_attempts: 2 - shell: cmd - command: 'dir' - - uses: nick-invision/assert-action@v1 - with: - expected: 2 - actual: ${{ steps.wrong_shell.outputs.total_attempts }} - - uses: nick-invision/assert-action@v1 - with: - expected: failure - actual: ${{ steps.wrong_shell.outcome }} - - ci_integration_envvar: - name: Run Integration Env Var Tests - runs-on: ubuntu-latest - steps: - - name: Checkout - uses: actions/checkout@v4 - - name: Setup Node.js - uses: actions/setup-node@v4 - with: - node-version: 20 - - name: Install dependencies - run: npm ci - - name: env-vars-passed-through - uses: ./ - env: - NODE_OPTIONS: '--max_old_space_size=3072' - with: - timeout_minutes: 1 - max_attempts: 2 - command: node -e 'console.log(process.env.NODE_OPTIONS)' - - ci_integration_large_output: - name: Run Integration Large Output Tests - runs-on: ubuntu-latest - steps: - - name: Checkout - uses: actions/checkout@v4 - - name: Setup Node.js - uses: actions/setup-node@v4 - with: - node-version: 20 - - name: Install dependencies - run: npm ci - - name: Test 100MiB of output can be processed - id: large-output - continue-on-error: true - uses: ./ - with: - max_attempts: 1 - timeout_minutes: 5 - command: 'make -C ./test-data/large-output bytes-102400' - - name: Assert test had expected result - uses: nick-invision/assert-action@v1 - with: - expected: failure - actual: ${{ steps.large-output.outcome }} - - name: Assert exit code is expected - uses: nick-invision/assert-action@v1 - with: - expected: 2 - actual: ${{ steps.large-output.outputs.exit_code }} - - ci_integration_retry_on_exit_code: - name: Run Integration retry_on_exit_code Tests - runs-on: ubuntu-latest - steps: - - name: Checkout - uses: actions/checkout@v4 - - name: Setup Node.js - uses: actions/setup-node@v4 - with: - node-version: 20 - - name: Install dependencies - run: npm ci - - name: retry_on_exit_code (with expected error code) - id: retry_on_exit_code_expected - uses: ./ - continue-on-error: true - with: - timeout_minutes: 1 - retry_on_exit_code: 2 - max_attempts: 3 - command: node -e "process.exit(2)" - - uses: nick-invision/assert-action@v1 - with: - expected: failure - actual: ${{ steps.retry_on_exit_code_expected.outcome }} - - uses: nick-invision/assert-action@v1 - with: - expected: 3 - actual: ${{ steps.retry_on_exit_code_expected.outputs.total_attempts }} - - - name: retry_on_exit_code (with unexpected error code) - id: retry_on_exit_code_unexpected - uses: ./ - continue-on-error: true - with: - timeout_minutes: 1 - retry_on_exit_code: 2 - max_attempts: 3 - command: node -e "process.exit(1)" - - uses: nick-invision/assert-action@v1 - with: - expected: failure - actual: ${{ steps.retry_on_exit_code_unexpected.outcome }} - - uses: nick-invision/assert-action@v1 - with: - expected: 1 - actual: ${{ steps.retry_on_exit_code_unexpected.outputs.total_attempts }} - - ci_integration_continue_on_error: - name: Run Integration continue_on_error Tests - runs-on: ubuntu-latest - steps: - - name: Checkout - uses: actions/checkout@v4 - - name: Setup Node.js - uses: actions/setup-node@v4 - with: - node-version: 20 - - name: Install dependencies - run: npm ci - - name: happy-path (continue_on_error) - id: happy_path_continue_on_error - uses: ./ - with: - command: node -e "process.exit(0)" - timeout_minutes: 1 - continue_on_error: true - - name: sad-path (continue_on_error) - id: sad_path_continue_on_error - uses: ./ - with: - command: node -e "process.exit(33)" - timeout_minutes: 1 - continue_on_error: true - - name: Verify continue_on_error returns correct exit code on success - uses: nick-invision/assert-action@v1 - with: - expected: 0 - actual: ${{ steps.happy_path_continue_on_error.outputs.exit_code }} - - name: Verify continue_on_error exits with correct outcome on success - uses: nick-invision/assert-action@v1 - with: - expected: success - actual: ${{ steps.happy_path_continue_on_error.outcome }} - - name: Verify continue_on_error returns correct exit code on error - uses: nick-invision/assert-action@v1 - with: - expected: 33 - actual: ${{ steps.sad_path_continue_on_error.outputs.exit_code }} - - name: Verify continue_on_error exits with successful outcome when an error occurs - uses: nick-invision/assert-action@v1 - with: - expected: success - actual: ${{ steps.sad_path_continue_on_error.outcome }} - - ci_integration_retry_wait_seconds: - name: Run Integration Tests (retry_wait_seconds) - runs-on: ubuntu-latest - steps: - - name: Checkout - uses: actions/checkout@v4 - - name: Setup Node.js - uses: actions/setup-node@v4 - with: - node-version: 20 - - name: Install dependencies - run: npm ci - - - name: sad-path (retry_wait_seconds) - id: sad_path_wait_sec - uses: ./ - continue-on-error: true - with: - timeout_minutes: 1 - max_attempts: 3 - retry_wait_seconds: 15 - command: npm install this-isnt-a-real-package-name-zzz - - uses: nick-invision/assert-action@v1 - with: - expected: 3 - actual: ${{ steps.sad_path_wait_sec.outputs.total_attempts }} - - uses: nick-invision/assert-action@v1 - with: - expected: failure - actual: ${{ steps.sad_path_wait_sec.outcome }} - - uses: nick-invision/assert-action@v1 - with: - expected: 'Final attempt failed' - actual: ${{ steps.sad_path_wait_sec.outputs.exit_error }} - comparison: contains - - ci_integration_on_retry_cmd: - name: Run Integration Tests (on_retry_command) - runs-on: ubuntu-latest - steps: - - name: Checkout - uses: actions/checkout@v4 - - name: Setup Node.js - uses: actions/setup-node@v4 - with: - node-version: 20 - - name: Install dependencies - run: npm ci - - - name: new-command-on-retry - id: new-command-on-retry - uses: ./ - with: - timeout_minutes: 1 - max_attempts: 3 - command: node -e "process.exit(1)" - new_command_on_retry: node -e "console.log('this is the new command on retry')" - - - name: on-retry-cmd - id: on-retry-cmd - uses: ./ - continue-on-error: true - with: - timeout_minutes: 1 - max_attempts: 3 - command: node -e "process.exit(1)" - on_retry_command: node -e "console.log('this is a retry command')" - - - name: on-retry-cmd (on-retry fails) - id: on-retry-cmd-fails - uses: ./ - continue-on-error: true - with: - timeout_minutes: 1 - max_attempts: 3 - command: node -e "process.exit(1)" - on_retry_command: node -e "throw new Error('This is an on-retry command error')" - - # timeout tests take longer to run so run in parallel - ci_integration_timeout_seconds: - name: Run Integration Timeout Tests (seconds) - runs-on: ubuntu-latest - steps: - - name: Checkout - uses: actions/checkout@v4 - - name: Setup Node.js - uses: actions/setup-node@v4 - with: - node-version: 20 - - name: Install dependencies - run: npm ci - - - name: sad-path (timeout) - id: sad_path_timeout - uses: ./ - continue-on-error: true - with: - timeout_seconds: 15 - max_attempts: 2 - command: node -e "(async()=>await new Promise(r => setTimeout(r, 120000)))()" - - uses: nick-invision/assert-action@v1 - with: - expected: 2 - actual: ${{ steps.sad_path_timeout.outputs.total_attempts }} - - uses: nick-invision/assert-action@v1 - with: - expected: failure - actual: ${{ steps.sad_path_timeout.outcome }} - - ci_integration_timeout_retry_on_timeout: - name: Run Integration Timeout Tests (retry_on timeout) - runs-on: ubuntu-latest - steps: - - name: Checkout - uses: actions/checkout@v4 - - name: Setup Node.js - uses: actions/setup-node@v4 - with: - node-version: 20 - - name: Install dependencies - run: npm ci - - - name: retry_on (timeout) - id: retry_on_timeout - uses: ./ - continue-on-error: true - with: - timeout_seconds: 15 - max_attempts: 2 - retry_on: timeout - command: node -e "(async()=>await new Promise(r => setTimeout(r, 120000)))()" - - uses: nick-invision/assert-action@v1 - with: - expected: 2 - actual: ${{ steps.retry_on_timeout.outputs.total_attempts }} - - uses: nick-invision/assert-action@v1 - with: - expected: failure - actual: ${{ steps.retry_on_timeout.outcome }} - - ci_integration_timeout_retry_on_error: - name: Run Integration Timeout Tests (retry_on error) - runs-on: ubuntu-latest - steps: - - name: Checkout - uses: actions/checkout@v4 - - name: Setup Node.js - uses: actions/setup-node@v4 - with: - node-version: 20 - - name: Install dependencies - run: npm ci - - - name: retry_on (error) fails early if timeout encountered - id: retry_on_error_fail - uses: ./ - continue-on-error: true - with: - timeout_seconds: 15 - max_attempts: 2 - retry_on: error - command: node -e "(async()=>await new Promise(r => setTimeout(r, 120000)))()" - - uses: nick-invision/assert-action@v1 - with: - expected: 1 - actual: ${{ steps.retry_on_error_fail.outputs.total_attempts }} - - uses: nick-invision/assert-action@v1 - with: - expected: failure - actual: ${{ steps.retry_on_error_fail.outcome }} - - uses: nick-invision/assert-action@v1 - with: - expected: 1 - actual: ${{ steps.retry_on_error_fail.outputs.exit_code }} - - ci_integration_timeout_minutes: - name: Run Integration Timeout Tests (minutes) - runs-on: ubuntu-latest - steps: - - name: Checkout - uses: actions/checkout@v4 - - name: Setup Node.js - uses: actions/setup-node@v4 - with: - node-version: 20 - - name: Install dependencies - run: npm ci - - - name: sad-path (timeout minutes) - id: sad_path_timeout_minutes - uses: ./ - continue-on-error: true - with: - timeout_minutes: 1 - max_attempts: 2 - command: node -e "(async()=>await new Promise(r => setTimeout(r, 120000)))()" - - uses: nick-invision/assert-action@v1 - with: - expected: 2 - actual: ${{ steps.sad_path_timeout_minutes.outputs.total_attempts }} - - uses: nick-invision/assert-action@v1 - with: - expected: failure - actual: ${{ steps.sad_path_timeout_minutes.outcome }} - - ci_windows: - name: Run Windows Tests - runs-on: windows-latest - steps: - - name: Checkout - uses: actions/checkout@v4 - - name: Setup Node.js - uses: actions/setup-node@v4 - with: - node-version: 20 - - name: Install dependencies - run: npm ci - - name: Powershell test - uses: ./ - with: - timeout_minutes: 1 - max_attempts: 2 - shell: powershell - command: Get-ComputerInfo - - name: CMD.exe test - uses: ./ - with: - timeout_minutes: 1 - max_attempts: 2 - shell: cmd - command: echo %PATH% - - name: Python test - uses: ./ - with: - timeout_minutes: 1 - max_attempts: 2 - shell: python - command: print('1', '2', '3') - - name: Multi-line multi-command Test - uses: ./ - with: - timeout_minutes: 1 - max_attempts: 2 - command: | - Get-ComputerInfo - Get-Date - - name: Multi-line single-command Test - uses: ./ - with: - timeout_minutes: 1 - max_attempts: 2 - shell: cmd - command: >- - echo "this is - a test" - - ci_all_tests_passed: - name: All tests passed - needs: - [ - ci_unit, - ci_integration, - ci_integration_envvar, - ci_integration_large_output, - ci_integration_on_retry_cmd, - ci_integration_retry_wait_seconds, - ci_integration_continue_on_error, - ci_integration_retry_on_exit_code, - ci_integration_timeout_seconds, - ci_integration_timeout_minutes, - ci_integration_timeout_retry_on_timeout, - ci_integration_timeout_retry_on_error, - ci_windows, - ] - runs-on: ubuntu-latest - steps: - - run: echo "If this is hit, all tests successfully passed" - - # runs on merge to default only - cd: - name: Publish Action - needs: [ci_all_tests_passed] - if: github.ref == 'refs/heads/master' - runs-on: ubuntu-latest - steps: - - name: Checkout - uses: actions/checkout@v4 - - name: Setup Node.js - uses: actions/setup-node@v4 - with: - node-version: 20 - - name: Install dependencies - run: npm ci - - name: Release - id: semantic - uses: cycjimmy/semantic-release-action@v4 - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - - name: Tag - run: git tag -f v${MAJOR_VERSION} && git push -f origin v${MAJOR_VERSION} - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - MAJOR_VERSION: ${{ steps.semantic.outputs.new_release_major_version }} diff --git a/README.md b/README.md index fa0baed..d91275a 100644 --- a/README.md +++ b/README.md @@ -1,21 +1,9 @@ # retry -Retries an Action step on failure or timeout. This is currently intended to replace the `run` step for moody commands. - -**NOTE:** Ownership of this project was transferred to my personal account `nick-fields` from my work account `nick-invision`. Details [here](#Ownership) - ---- +Retries an Action step on failure. Determines if a failure is a flake based on the test output ## Inputs -### `timeout_minutes` - -**Required** Minutes to wait before attempt times out. Must only specify either minutes or seconds - -### `timeout_seconds` - -**Required** Seconds to wait before attempt times out. Must only specify either minutes or seconds - ### `max_attempts` **Required** Number of attempts to make before failing the step @@ -24,219 +12,26 @@ Retries an Action step on failure or timeout. This is currently intended to repl **Required** The command to run -### `retry_wait_seconds` - -**Optional** Number of seconds to wait before attempting the next retry. Defaults to `10` - -### `shell` - -**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) - -### `polling_interval_seconds` - -**Optional** Number of seconds to wait while polling for command result. Defaults to `1` - -### `retry_on` - -**Optional** Event to retry on. Currently supports [any (default), timeout, error]. - -### `warning_on_retry` - -**Optional** Whether to output a warning on retry, or just output to info. Defaults to `true`. - -### `on_retry_command` - -**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. - -### `new_command_on_retry` - -**Optional** Command to run if the first attempt fails. This command will be called on all subsequent attempts. - -### `continue_on_error` - -**Optional** Exit successfully even if an error occurs. Same as native continue-on-error behavior, but for use in composite actions. Defaults to `false` +### `substrings_indicating_flaky_execution` -### `retry_on_exit_code` - -**Optional** Specific exit code to retry on. This will only retry for the given error code and fail immediately other error codes. - -## Outputs - -### `total_attempts` - -The final number of attempts made - -### `exit_code` - -The final exit code returned by the command - -### `exit_error` - -The final error returned by the command +**Optional** Execution is considered a flake if any output line contains any of these lines as a substring. Note - if not specified, all failures are considered as real failures. ## Examples -### Shell - -```yaml -uses: nick-fields/retry@v3 -with: - timeout_minutes: 10 - max_attempts: 3 - shell: pwsh - command: dir -``` - -### Timeout in minutes - -```yaml -uses: nick-fields/retry@v3 -with: - timeout_minutes: 10 - max_attempts: 3 - command: npm run some-typically-slow-script -``` - -### Timeout in seconds - ```yaml -uses: nick-fields/retry@v3 +uses: oppia/retry@develop with: - timeout_seconds: 15 - max_attempts: 3 - command: npm run some-typically-fast-script -``` - -### Only retry after timeout - -```yaml -uses: nick-fields/retry@v3 -with: - timeout_seconds: 15 - max_attempts: 3 - retry_on: timeout - command: npm run some-typically-fast-script -``` - -### Only retry after error - -```yaml -uses: nick-fields/retry@v3 -with: - timeout_seconds: 15 - max_attempts: 3 - retry_on: error - command: npm run some-typically-fast-script -``` - -### Retry using continue_on_error input (in composite action) but allow failure and do something with output - -```yaml -- uses: nick-fields/retry@v3 - id: retry - with: - timeout_seconds: 15 - max_attempts: 3 - continue_on_error: true - command: node -e 'process.exit(99);' -- name: Assert that step succeeded (despite failing command) - uses: nick-fields/assert-action@v1 - with: - expected: success - actual: ${{ steps.retry.outcome }} -- name: Assert that action exited with expected exit code - uses: nick-fields/assert-action@v1 - with: - expected: 99 - actual: ${{ steps.retry.outputs.exit_code }} -``` - -### Retry using continue-on-error built-in command (in workflow action) but allow failure and do something with output - -```yaml -- uses: nick-fields/retry@v3 - id: retry - # see https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idcontinue-on-error - continue-on-error: true - with: - timeout_seconds: 15 - max_attempts: 3 - retry_on: error - command: node -e 'process.exit(99);' -- name: Assert that action failed - uses: nick-fields/assert-action@v1 - with: - expected: failure - actual: ${{ steps.retry.outcome }} -- name: Assert that action exited with expected exit code - uses: nick-fields/assert-action@v1 - with: - expected: 99 - actual: ${{ steps.retry.outputs.exit_code }} -- name: Assert that action made expected number of attempts - uses: nick-fields/assert-action@v1 - with: - expected: 3 - actual: ${{ steps.retry.outputs.total_attempts }} -``` - -### Run script after failure but before retry - -```yaml -uses: nick-fields/retry@v3 -with: - timeout_seconds: 15 - max_attempts: 3 - command: npm run some-flaky-script-that-outputs-something - on_retry_command: npm run cleanup-flaky-script-output -``` - -### Run different command after first failure - -```yaml -uses: nick-fields/retry@v3 -with: - timeout_seconds: 15 - max_attempts: 3 - command: npx jest - new_command_on_retry: npx jest --onlyFailures -``` - -### Run multi-line, multi-command script - -```yaml -name: Multi-line multi-command Test -uses: ./ -with: - timeout_minutes: 1 - max_attempts: 2 - command: | - Get-ComputerInfo - Get-Date -``` - -### Run multi-line, single-command script - -```yaml -name: Multi-line single-command Test -uses: ./ -with: - timeout_minutes: 1 max_attempts: 2 - shell: cmd - command: >- - echo "this is - a test" + substrings_indicating_flaky_execution: | + First flaky substring + Second flaky substring + command: ./run_tests.sh ``` -## Requirements - -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. - ---- +## Commands -## **Ownership** +`npm install` to install dependencies. -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. +`npm run prepare` to build dist/index.js. -Existing workflow references to `nick-invision/retry@` no longer work and must be updated to `nick-fields/retry@`. +`npm test` to run tests. diff --git a/action.yml b/action.yml index 70dbf74..830162f 100644 --- a/action.yml +++ b/action.yml @@ -1,54 +1,16 @@ name: Retry Step -description: 'Retry a step on failure or timeout' +description: 'Retry a step on failure' inputs: - timeout_minutes: - description: Minutes to wait before attempt times out. Must only specify either minutes or seconds - required: false - timeout_seconds: - description: Seconds to wait before attempt times out. Must only specify either minutes or seconds - required: false max_attempts: description: Number of attempts to make before failing the step required: true - default: 3 + default: 2 command: description: The command to run required: true - retry_wait_seconds: - description: Number of seconds to wait before attempting the next retry - required: false - default: 10 - shell: - description: Alternate shell to use (defaults to powershell on windows, bash otherwise). Supports bash, python, pwsh, sh, cmd, and powershell - required: false - polling_interval_seconds: - description: Number of seconds to wait for each check that command has completed running - required: false - default: 1 - retry_on: - description: Event to retry on. Currently supported [any, timeout, error] - warning_on_retry: - description: Whether to output a warning on retry, or just output to info. Defaults to true - default: true - on_retry_command: - 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. - required: false - continue_on_error: - description: Exits successfully even if an error occurs. Same as native continue-on-error behavior, but for use in composite actions. Default is false - default: false - new_command_on_retry: - description: Command to run if the first attempt fails. This command will be called on all subsequent attempts. - required: false - retry_on_exit_code: - description: Specific exit code to retry on. This will only retry for the given error code and fail immediately other error codes. + substrings_indicating_flaky_execution: + description: Specify which lines in output indicate that the failure is flaky. Note - if not specified, all failures are considered as real failures. required: false -outputs: - total_attempts: - description: The final number of attempts made - exit_code: - description: The final exit code returned by the command - exit_error: - description: The final error returned by the command runs: using: 'node20' main: 'dist/index.js' diff --git a/dist/index.js b/dist/index.js index 0072142..f107310 100644 --- a/dist/index.js +++ b/dist/index.js @@ -1851,132 +1851,6 @@ module.exports = { }; -/***/ }), - -/***/ 9335: -/***/ ((module, __unused_webpack_exports, __nccwpck_require__) => { - -"use strict"; - - -var childProcess = __nccwpck_require__(2081); -var spawn = childProcess.spawn; -var exec = childProcess.exec; - -module.exports = function (pid, signal, callback) { - if (typeof signal === 'function' && callback === undefined) { - callback = signal; - signal = undefined; - } - - pid = parseInt(pid); - if (Number.isNaN(pid)) { - if (callback) { - return callback(new Error("pid must be a number")); - } else { - throw new Error("pid must be a number"); - } - } - - var tree = {}; - var pidsToProcess = {}; - tree[pid] = []; - pidsToProcess[pid] = 1; - - switch (process.platform) { - case 'win32': - exec('taskkill /pid ' + pid + ' /T /F', callback); - break; - case 'darwin': - buildProcessTree(pid, tree, pidsToProcess, function (parentPid) { - return spawn('pgrep', ['-P', parentPid]); - }, function () { - killAll(tree, signal, callback); - }); - break; - // case 'sunos': - // buildProcessTreeSunOS(pid, tree, pidsToProcess, function () { - // killAll(tree, signal, callback); - // }); - // break; - default: // Linux - buildProcessTree(pid, tree, pidsToProcess, function (parentPid) { - return spawn('ps', ['-o', 'pid', '--no-headers', '--ppid', parentPid]); - }, function () { - killAll(tree, signal, callback); - }); - break; - } -}; - -function killAll (tree, signal, callback) { - var killed = {}; - try { - Object.keys(tree).forEach(function (pid) { - tree[pid].forEach(function (pidpid) { - if (!killed[pidpid]) { - killPid(pidpid, signal); - killed[pidpid] = 1; - } - }); - if (!killed[pid]) { - killPid(pid, signal); - killed[pid] = 1; - } - }); - } catch (err) { - if (callback) { - return callback(err); - } else { - throw err; - } - } - if (callback) { - return callback(); - } -} - -function killPid(pid, signal) { - try { - process.kill(parseInt(pid, 10), signal); - } - catch (err) { - if (err.code !== 'ESRCH') throw err; - } -} - -function buildProcessTree (parentPid, tree, pidsToProcess, spawnChildProcessesList, cb) { - var ps = spawnChildProcessesList(parentPid); - var allData = ''; - ps.stdout.on('data', function (data) { - var data = data.toString('ascii'); - allData += data; - }); - - var onClose = function (code) { - delete pidsToProcess[parentPid]; - - if (code != 0) { - // no more parent processes - if (Object.keys(pidsToProcess).length == 0) { - cb(); - } - return; - } - - allData.match(/\d+/g).forEach(function (pid) { - pid = parseInt(pid, 10); - tree[parentPid].push(pid); - tree[pid] = []; - pidsToProcess[pid] = 1; - buildProcessTree(pid, tree, pidsToProcess, spawnChildProcessesList, cb); - }); - }; - - ps.on('close', onClose); -} - - /***/ }), /***/ 4294: @@ -24847,11 +24721,24 @@ exports["default"] = _default; /***/ }), -/***/ 6144: +/***/ 7672: /***/ (function(__unused_webpack_module, exports, __nccwpck_require__) { "use strict"; +// Copyright 2024 The Oppia Authors. All Rights Reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS-IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) { function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); } return new (P || (P = Promise))(function (resolve, reject) { @@ -24892,423 +24779,156 @@ var __importDefault = (this && this.__importDefault) || function (mod) { return (mod && mod.__esModule) ? mod : { "default": mod }; }; Object.defineProperty(exports, "__esModule", ({ value: true })); +exports.runAction = void 0; +/** + * @fileoverview Action implementation. + */ var core_1 = __nccwpck_require__(2186); var child_process_1 = __nccwpck_require__(2081); var milliseconds_1 = __importDefault(__nccwpck_require__(2318)); -var tree_kill_1 = __importDefault(__nccwpck_require__(9335)); -var inputs_1 = __nccwpck_require__(7063); -var util_1 = __nccwpck_require__(2629); var OS = process.platform; -var OUTPUT_TOTAL_ATTEMPTS_KEY = 'total_attempts'; -var OUTPUT_EXIT_CODE_KEY = 'exit_code'; -var OUTPUT_EXIT_ERROR_KEY = 'exit_error'; -var exit; -var done; -function getExecutable(inputs) { - if (!inputs.shell) { - return OS === 'win32' ? 'powershell' : 'bash'; - } - var executable; - var shellName = inputs.shell.split(' ')[0]; - switch (shellName) { - case 'bash': - case 'python': - case 'pwsh': { - executable = inputs.shell; - break; - } - case 'sh': { - if (OS === 'win32') { - throw new Error("Shell ".concat(shellName, " not allowed on OS ").concat(OS)); - } - executable = inputs.shell; - break; - } - case 'cmd': - case 'powershell': { - if (OS !== 'win32') { - throw new Error("Shell ".concat(shellName, " not allowed on OS ").concat(OS)); - } - executable = shellName + '.exe' + inputs.shell.replace(shellName, ''); - break; - } - default: { - throw new Error("Shell ".concat(shellName, " not supported. See https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idstepsshell for supported shells")); - } - } - return executable; +function getExecutable() { + return OS === 'win32' ? 'powershell' : 'bash'; } -function runRetryCmd(inputs) { +function wait(ms) { return __awaiter(this, void 0, void 0, function () { - var error_1; return __generator(this, function (_a) { - switch (_a.label) { - case 0: - // if no retry script, just continue - if (!inputs.on_retry_command) { - return [2 /*return*/]; - } - _a.label = 1; - case 1: - _a.trys.push([1, 3, , 4]); - return [4 /*yield*/, (0, child_process_1.execSync)(inputs.on_retry_command, { stdio: 'inherit' })]; - case 2: - _a.sent(); - return [3 /*break*/, 4]; - case 3: - error_1 = _a.sent(); - (0, core_1.info)("WARNING: Retry command threw the error ".concat(error_1.message)); - return [3 /*break*/, 4]; - case 4: return [2 /*return*/]; - } + return [2 /*return*/, new Promise(function (r) { return setTimeout(r, ms); })]; }); }); } -function runCmd(attempt, inputs) { +function runCommand(inputs) { var _a, _b; return __awaiter(this, void 0, void 0, function () { - var end_time, executable, timeout, child; + var endTime, executable, exitCode, done, output, child, pollingPeriod; return __generator(this, function (_c) { switch (_c.label) { case 0: - end_time = Date.now() + (0, inputs_1.getTimeout)(inputs); - executable = getExecutable(inputs); - exit = 0; + endTime = Date.now() + milliseconds_1.default.hours(5); + executable = getExecutable(); + exitCode = 124; done = false; - timeout = false; + output = []; (0, core_1.debug)("Running command ".concat(inputs.command, " on ").concat(OS, " using shell ").concat(executable)); - child = attempt > 1 && inputs.new_command_on_retry - ? (0, child_process_1.spawn)(inputs.new_command_on_retry, { shell: executable }) - : (0, child_process_1.spawn)(inputs.command, { shell: executable }); + child = (0, child_process_1.spawn)(inputs.command, { shell: executable }); (_a = child.stdout) === null || _a === void 0 ? void 0 : _a.on('data', function (data) { process.stdout.write(data); + output.push(data); }); (_b = child.stderr) === null || _b === void 0 ? void 0 : _b.on('data', function (data) { process.stdout.write(data); + output.push(data); }); - child.on('exit', function (code, signal) { + child.on('exit', function (code) { (0, core_1.debug)("Code: ".concat(code)); - (0, core_1.debug)("Signal: ".concat(signal)); - // timeouts are killed manually - if (signal === 'SIGTERM') { - return; - } - // On Windows signal is null. - if (timeout) { + if (code === null) { + (0, core_1.error)('exit code cannot be null'); + exitCode = 1; return; } - if (code && code > 0) { - exit = code; - } + exitCode = code; done = true; }); _c.label = 1; - case 1: return [4 /*yield*/, (0, util_1.wait)(milliseconds_1.default.seconds(inputs.polling_interval_seconds))]; + case 1: + pollingPeriod = milliseconds_1.default.seconds(1); + return [4 /*yield*/, wait(pollingPeriod)]; case 2: _c.sent(); _c.label = 3; case 3: - if (Date.now() < end_time && !done) return [3 /*break*/, 1]; + if (Date.now() < endTime && !done) return [3 /*break*/, 1]; _c.label = 4; - case 4: - if (!(!done && child.pid)) return [3 /*break*/, 6]; - timeout = true; - (0, tree_kill_1.default)(child.pid); - return [4 /*yield*/, (0, util_1.retryWait)(milliseconds_1.default.seconds(inputs.retry_wait_seconds))]; - case 5: - _c.sent(); - throw new Error("Timeout of ".concat((0, inputs_1.getTimeout)(inputs), "ms hit")); - case 6: - if (!(exit > 0)) return [3 /*break*/, 8]; - return [4 /*yield*/, (0, util_1.retryWait)(milliseconds_1.default.seconds(inputs.retry_wait_seconds))]; - case 7: - _c.sent(); - throw new Error("Child_process exited with error code ".concat(exit)); - case 8: return [2 /*return*/]; + case 4: return [2 /*return*/, { + success: exitCode === 0, + exitCode: exitCode, + output: output, + }]; } }); }); } +function hasFlakyOutput(substrings_indicating_flaky_execution, output) { + var flakyIndicator = substrings_indicating_flaky_execution.find(function (flakyLine) { + return output.some(function (outputLine) { return outputLine.includes(flakyLine); }); + }); + if (flakyIndicator === undefined) { + return false; + } + (0, core_1.info)("Found flaky indicator: ".concat(flakyIndicator)); + return true; +} function runAction(inputs) { return __awaiter(this, void 0, void 0, function () { - var attempt, error_2; - return __generator(this, function (_a) { - switch (_a.label) { - case 0: return [4 /*yield*/, (0, inputs_1.validateInputs)(inputs)]; - case 1: - _a.sent(); + var attempt, _a, success, exitCode, output; + return __generator(this, function (_b) { + switch (_b.label) { + case 0: attempt = 1; - _a.label = 2; + _b.label = 1; + case 1: + if (!(attempt <= inputs.maxAttempts)) return [3 /*break*/, 4]; + (0, core_1.info)("Starting attempt #".concat(attempt)); + return [4 /*yield*/, runCommand(inputs)]; case 2: - if (!(attempt <= inputs.max_attempts)) return [3 /*break*/, 13]; - _a.label = 3; - case 3: - _a.trys.push([3, 5, , 12]); - // just keep overwriting attempts output - (0, core_1.setOutput)(OUTPUT_TOTAL_ATTEMPTS_KEY, attempt); - return [4 /*yield*/, runCmd(attempt, inputs)]; - case 4: - _a.sent(); - (0, core_1.info)("Command completed after ".concat(attempt, " attempt(s).")); - return [3 /*break*/, 13]; - case 5: - error_2 = _a.sent(); - if (!(attempt === inputs.max_attempts)) return [3 /*break*/, 6]; - throw new Error("Final attempt failed. ".concat(error_2.message)); - case 6: - if (!(!done && inputs.retry_on === 'error')) return [3 /*break*/, 7]; - // error: timeout - throw error_2; - case 7: - if (!(inputs.retry_on_exit_code && inputs.retry_on_exit_code !== exit)) return [3 /*break*/, 8]; - throw error_2; - case 8: - if (!(exit > 0 && inputs.retry_on === 'timeout')) return [3 /*break*/, 9]; - // error: error - throw error_2; - case 9: return [4 /*yield*/, runRetryCmd(inputs)]; - case 10: - _a.sent(); - if (inputs.warning_on_retry) { - (0, core_1.warning)("Attempt ".concat(attempt, " failed. Reason: ").concat(error_2.message)); + _a = _b.sent(), success = _a.success, exitCode = _a.exitCode, output = _a.output; + if (success) { + (0, core_1.info)("Attempt #".concat(attempt, " succeeded")); + return [2 /*return*/, 0]; } - else { - (0, core_1.info)("Attempt ".concat(attempt, " failed. Reason: ").concat(error_2.message)); + (0, core_1.info)("Attempt #".concat(attempt, " failed with exit code ").concat(exitCode)); + if (attempt == inputs.maxAttempts) { + return [2 /*return*/, exitCode]; } - _a.label = 11; - case 11: return [3 /*break*/, 12]; - case 12: + if (!hasFlakyOutput(inputs.substringsIndicatingFlakyExecution, output)) { + (0, core_1.info)("Output doesn't contain flaky indicators, considering it a failure"); + return [2 /*return*/, exitCode]; + } + (0, core_1.info)('Output contains flaky indicators, restarting the test'); + _b.label = 3; + case 3: attempt++; - return [3 /*break*/, 2]; - case 13: return [2 /*return*/]; + return [3 /*break*/, 1]; + case 4: throw new Error('Unreachable'); } }); }); } -var inputs = (0, inputs_1.getInputs)(); -runAction(inputs) - .then(function () { - (0, core_1.setOutput)(OUTPUT_EXIT_CODE_KEY, 0); - process.exit(0); // success -}) - .catch(function (err) { - // exact error code if available, otherwise just 1 - var exitCode = exit > 0 ? exit : 1; - if (inputs.continue_on_error) { - (0, core_1.warning)(err.message); - } - else { - (0, core_1.error)(err.message); - } - // these can be helpful to know if continue-on-error is true - (0, core_1.setOutput)(OUTPUT_EXIT_ERROR_KEY, err.message); - (0, core_1.setOutput)(OUTPUT_EXIT_CODE_KEY, exitCode); - // if continue_on_error, exit with exact error code else exit gracefully - // mimics native continue-on-error that is not supported in composite actions - process.exit(inputs.continue_on_error ? 0 : exitCode); -}); +exports.runAction = runAction; /***/ }), /***/ 7063: -/***/ (function(__unused_webpack_module, exports, __nccwpck_require__) { +/***/ ((__unused_webpack_module, exports, __nccwpck_require__) => { "use strict"; -var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) { - function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); } - return new (P || (P = Promise))(function (resolve, reject) { - function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } } - function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } } - function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); } - step((generator = generator.apply(thisArg, _arguments || [])).next()); - }); -}; -var __generator = (this && this.__generator) || function (thisArg, body) { - var _ = { label: 0, sent: function() { if (t[0] & 1) throw t[1]; return t[1]; }, trys: [], ops: [] }, f, y, t, g; - return g = { next: verb(0), "throw": verb(1), "return": verb(2) }, typeof Symbol === "function" && (g[Symbol.iterator] = function() { return this; }), g; - function verb(n) { return function (v) { return step([n, v]); }; } - function step(op) { - if (f) throw new TypeError("Generator is already executing."); - while (g && (g = 0, op[0] && (_ = 0)), _) try { - if (f = 1, y && (t = op[0] & 2 ? y["return"] : op[0] ? y["throw"] || ((t = y["return"]) && t.call(y), 0) : y.next) && !(t = t.call(y, op[1])).done) return t; - if (y = 0, t) op = [op[0] & 2, t.value]; - switch (op[0]) { - case 0: case 1: t = op; break; - case 4: _.label++; return { value: op[1], done: false }; - case 5: _.label++; y = op[1]; op = [0]; continue; - case 7: op = _.ops.pop(); _.trys.pop(); continue; - default: - if (!(t = _.trys, t = t.length > 0 && t[t.length - 1]) && (op[0] === 6 || op[0] === 2)) { _ = 0; continue; } - if (op[0] === 3 && (!t || (op[1] > t[0] && op[1] < t[3]))) { _.label = op[1]; break; } - if (op[0] === 6 && _.label < t[1]) { _.label = t[1]; t = op; break; } - if (t && _.label < t[2]) { _.label = t[2]; _.ops.push(op); break; } - if (t[2]) _.ops.pop(); - _.trys.pop(); continue; - } - op = body.call(thisArg, _); - } catch (e) { op = [6, e]; y = 0; } finally { f = t = 0; } - if (op[0] & 5) throw op[1]; return { value: op[0] ? op[1] : void 0, done: true }; - } -}; -var __importDefault = (this && this.__importDefault) || function (mod) { - return (mod && mod.__esModule) ? mod : { "default": mod }; -}; Object.defineProperty(exports, "__esModule", ({ value: true })); -exports.getInputs = exports.getTimeout = exports.validateInputs = exports.getInputBoolean = exports.getInputNumber = void 0; +exports.getInputs = exports.getInputNumber = void 0; var core_1 = __nccwpck_require__(2186); -var milliseconds_1 = __importDefault(__nccwpck_require__(2318)); -function getInputNumber(id, required) { - var input = (0, core_1.getInput)(id, { required: required }); +function getInputNumber(id) { + var input = (0, core_1.getInput)(id, { required: true }); var num = Number.parseInt(input); - // empty is ok - if (!input && !required) { - return; - } if (!Number.isInteger(num)) { throw "Input ".concat(id, " only accepts numbers. Received ").concat(input); } return num; } exports.getInputNumber = getInputNumber; -function getInputBoolean(id) { - var input = (0, core_1.getInput)(id); - if (!['true', 'false'].includes(input.toLowerCase())) { - throw "Input ".concat(id, " only accepts boolean values. Received ").concat(input); - } - return input.toLowerCase() === 'true'; -} -exports.getInputBoolean = getInputBoolean; -function validateInputs(inputs) { - return __awaiter(this, void 0, void 0, function () { - return __generator(this, function (_a) { - if ((!inputs.timeout_minutes && !inputs.timeout_seconds) || - (inputs.timeout_minutes && inputs.timeout_seconds)) { - throw new Error('Must specify either timeout_minutes or timeout_seconds inputs'); - } - return [2 /*return*/]; - }); - }); -} -exports.validateInputs = validateInputs; -function getTimeout(inputs) { - if (inputs.timeout_minutes) { - return milliseconds_1.default.minutes(inputs.timeout_minutes); - } - else if (inputs.timeout_seconds) { - return milliseconds_1.default.seconds(inputs.timeout_seconds); - } - throw new Error('Must specify either timeout_minutes or timeout_seconds inputs'); -} -exports.getTimeout = getTimeout; function getInputs() { - var timeout_minutes = getInputNumber('timeout_minutes', false); - var timeout_seconds = getInputNumber('timeout_seconds', false); - var max_attempts = getInputNumber('max_attempts', true) || 3; + var max_attempts = getInputNumber('max_attempts'); var command = (0, core_1.getInput)('command', { required: true }); - var retry_wait_seconds = getInputNumber('retry_wait_seconds', false) || 10; - var shell = (0, core_1.getInput)('shell'); - var polling_interval_seconds = getInputNumber('polling_interval_seconds', false) || 1; - var retry_on = (0, core_1.getInput)('retry_on') || 'any'; - var warning_on_retry = (0, core_1.getInput)('warning_on_retry').toLowerCase() === 'true'; - var on_retry_command = (0, core_1.getInput)('on_retry_command'); - var continue_on_error = getInputBoolean('continue_on_error'); - var new_command_on_retry = (0, core_1.getInput)('new_command_on_retry'); - var retry_on_exit_code = getInputNumber('retry_on_exit_code', false); + var substringsIndicatingFlakyExecution = (0, core_1.getMultilineInput)('substrings_indicating_flaky_execution'); return { - timeout_minutes: timeout_minutes, - timeout_seconds: timeout_seconds, - max_attempts: max_attempts, + maxAttempts: max_attempts, command: command, - retry_wait_seconds: retry_wait_seconds, - shell: shell, - polling_interval_seconds: polling_interval_seconds, - retry_on: retry_on, - warning_on_retry: warning_on_retry, - on_retry_command: on_retry_command, - continue_on_error: continue_on_error, - new_command_on_retry: new_command_on_retry, - retry_on_exit_code: retry_on_exit_code, + substringsIndicatingFlakyExecution: substringsIndicatingFlakyExecution, }; } exports.getInputs = getInputs; -/***/ }), - -/***/ 2629: -/***/ (function(__unused_webpack_module, exports, __nccwpck_require__) { - -"use strict"; - -var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) { - function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); } - return new (P || (P = Promise))(function (resolve, reject) { - function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } } - function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } } - function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); } - step((generator = generator.apply(thisArg, _arguments || [])).next()); - }); -}; -var __generator = (this && this.__generator) || function (thisArg, body) { - var _ = { label: 0, sent: function() { if (t[0] & 1) throw t[1]; return t[1]; }, trys: [], ops: [] }, f, y, t, g; - return g = { next: verb(0), "throw": verb(1), "return": verb(2) }, typeof Symbol === "function" && (g[Symbol.iterator] = function() { return this; }), g; - function verb(n) { return function (v) { return step([n, v]); }; } - function step(op) { - if (f) throw new TypeError("Generator is already executing."); - while (g && (g = 0, op[0] && (_ = 0)), _) try { - if (f = 1, y && (t = op[0] & 2 ? y["return"] : op[0] ? y["throw"] || ((t = y["return"]) && t.call(y), 0) : y.next) && !(t = t.call(y, op[1])).done) return t; - if (y = 0, t) op = [op[0] & 2, t.value]; - switch (op[0]) { - case 0: case 1: t = op; break; - case 4: _.label++; return { value: op[1], done: false }; - case 5: _.label++; y = op[1]; op = [0]; continue; - case 7: op = _.ops.pop(); _.trys.pop(); continue; - default: - if (!(t = _.trys, t = t.length > 0 && t[t.length - 1]) && (op[0] === 6 || op[0] === 2)) { _ = 0; continue; } - if (op[0] === 3 && (!t || (op[1] > t[0] && op[1] < t[3]))) { _.label = op[1]; break; } - if (op[0] === 6 && _.label < t[1]) { _.label = t[1]; t = op; break; } - if (t && _.label < t[2]) { _.label = t[2]; _.ops.push(op); break; } - if (t[2]) _.ops.pop(); - _.trys.pop(); continue; - } - op = body.call(thisArg, _); - } catch (e) { op = [6, e]; y = 0; } finally { f = t = 0; } - if (op[0] & 5) throw op[1]; return { value: op[0] ? op[1] : void 0, done: true }; - } -}; -Object.defineProperty(exports, "__esModule", ({ value: true })); -exports.retryWait = exports.wait = void 0; -var core_1 = __nccwpck_require__(2186); -function wait(ms) { - return __awaiter(this, void 0, void 0, function () { - return __generator(this, function (_a) { - return [2 /*return*/, new Promise(function (r) { return setTimeout(r, ms); })]; - }); - }); -} -exports.wait = wait; -function retryWait(retryWaitSeconds) { - return __awaiter(this, void 0, void 0, function () { - var waitStart; - return __generator(this, function (_a) { - switch (_a.label) { - case 0: - waitStart = Date.now(); - return [4 /*yield*/, wait(retryWaitSeconds)]; - case 1: - _a.sent(); - (0, core_1.debug)("Waited ".concat(Date.now() - waitStart, "ms")); - (0, core_1.debug)("Configured wait: ".concat(retryWaitSeconds, "ms")); - return [2 /*return*/]; - } - }); - }); -} -exports.retryWait = retryWait; - - /***/ }), /***/ 9491: @@ -27200,12 +26820,26 @@ module.exports = parseParams /******/ if (typeof __nccwpck_require__ !== 'undefined') __nccwpck_require__.ab = __dirname + "/"; /******/ /************************************************************************/ -/******/ -/******/ // startup -/******/ // Load entry module and return exports -/******/ // This entry module is referenced by other modules so it can't be inlined -/******/ var __webpack_exports__ = __nccwpck_require__(6144); -/******/ module.exports = __webpack_exports__; -/******/ +var __webpack_exports__ = {}; +// This entry need to be wrapped in an IIFE because it need to be in strict mode. +(() => { +"use strict"; +var exports = __webpack_exports__; + +Object.defineProperty(exports, "__esModule", ({ value: true })); +var core_1 = __nccwpck_require__(2186); +var inputs_1 = __nccwpck_require__(7063); +var action_1 = __nccwpck_require__(7672); +var inputs = (0, inputs_1.getInputs)(); +(0, action_1.runAction)(inputs) + .then(function (exitCode) { return process.exit(exitCode); }) + .catch(function (err) { + (0, core_1.error)("Failed test with exception ".concat(err.message)); + process.exit(1); +}); + +})(); + +module.exports = __webpack_exports__; /******/ })() ; \ No newline at end of file diff --git a/package.json b/package.json index 2bdb843..2c62ba2 100644 --- a/package.json +++ b/package.json @@ -24,8 +24,7 @@ "homepage": "https://github.com/nick-invision/retry#readme", "dependencies": { "@actions/core": "^1.10.0", - "milliseconds": "^1.0.3", - "tree-kill": "^1.2.2" + "milliseconds": "^1.0.3" }, "devDependencies": { "@commitlint/cli": "^16.2.3", diff --git a/sample.env b/sample.env deleted file mode 100644 index d4de8c6..0000000 --- a/sample.env +++ /dev/null @@ -1,11 +0,0 @@ -# these are the bare minimum envvars required -INPUT_TIMEOUT_MINUTES=1 -INPUT_MAX_ATTEMPTS=3 -INPUT_COMMAND="node -e 'process.exit(99)'" -INPUT_CONTINUE_ON_ERROR=false - -# these are optional -#INPUT_RETRY_WAIT_SECONDS=10 -#SHELL=pwsh -#INPUT_POLLING_INTERVAL_SECONDS=1 -#INPUT_RETRY_ON=any diff --git a/src/action.spec.ts b/src/action.spec.ts new file mode 100644 index 0000000..38f8c97 --- /dev/null +++ b/src/action.spec.ts @@ -0,0 +1,147 @@ +// Copyright 2024 The Oppia Authors. All Rights Reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS-IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +/** + * @fileoverview Retry action test. + */ + +import 'jest'; + +import { runAction } from './action'; +import { Inputs } from './inputs'; +import * as fs from 'fs'; + +function generateRandomString(length: number): string { + const letters = 'abcdefghijklmnopqrstuvwxyz'; + let result = ''; + + for (let i = 0; i < length; i++) { + const randomIndex = Math.floor(Math.random() * letters.length); + result += letters.charAt(randomIndex); + } + + return result; +} + +function createTempFile(content = ''): string { + const fileName = `/tmp/${generateRandomString(10)}`; + fs.writeFileSync(fileName, content); + return fileName; +} + +function assertFileContent(fileName: string, content: string) { + const data = fs.readFileSync(fileName, 'utf-8'); + expect(data).toBe(content); +} + +describe('retry', () => { + let fileName = ''; + beforeEach(() => { + fileName = createTempFile(); + }); + + it('retries, fails', async () => { + const inputs: Inputs = { + maxAttempts: 3, + command: `echo 'a line for a flake test' \\ + && echo -n 1 >> ${fileName} \\ + && false`, + substringsIndicatingFlakyExecution: ['another_flake', 'flake'], + }; + const exitCode = await runAction(inputs); + expect(exitCode).toBe(1); + + assertFileContent(fileName, '111'); + }); + + it('succeeds', async () => { + const inputs: Inputs = { + maxAttempts: 3, + command: `echo -n 1 >> ${fileName}`, + substringsIndicatingFlakyExecution: ['flake'], + }; + const exitCode = await runAction(inputs); + expect(exitCode).toBe(0); + + assertFileContent(fileName, '1'); + }); + + it('succeeds with empty flaky lines', async () => { + const inputs: Inputs = { + maxAttempts: 3, + command: `echo -n 1 >> ${fileName}`, + substringsIndicatingFlakyExecution: [], + }; + const exitCode = await runAction(inputs); + expect(exitCode).toBe(0); + + assertFileContent(fileName, '1'); + }); + + it('succeeds after flake', async () => { + const inputs: Inputs = { + maxAttempts: 3, + // command succeeds on the second run + command: `echo flake \\ + && echo -n 1 >> ${fileName} \\ + && grep 11 ${fileName}`, + substringsIndicatingFlakyExecution: ['flake'], + }; + const exitCode = await runAction(inputs); + expect(exitCode).toBe(0); + + assertFileContent(fileName, '11'); + }); + + it('detects real errors based on output', async () => { + const inputs: Inputs = { + maxAttempts: 3, + command: `echo -n 1 >> ${fileName} \\ + && echo 'real error, not flaky' \\ + && false`, + substringsIndicatingFlakyExecution: ['flaky_string'], + }; + const exitCode = await runAction(inputs); + expect(exitCode).toBe(1); + + assertFileContent(fileName, '1'); + }); + + it('detects real errors after flakes', async () => { + // The second file is used to indicate the flake. + const secondFileName = createTempFile('flaky_string'); + const inputs: Inputs = { + maxAttempts: 3, + // The first execution will output "flaky_string". + // The second execution will output "1" + // because we overwrite the second file with "1" during the first execution. + // The second execution should not be treated as a flake. + command: `cat ${secondFileName} \\ + && echo 1 > ${secondFileName} \\ + && echo -n 1 >> ${fileName} \\ + && false`, + substringsIndicatingFlakyExecution: ['flaky_string'], + }; + const exitCode = await runAction(inputs); + expect(exitCode).toBe(1); + + // assert executed only twice + assertFileContent(fileName, '11'); + fs.unlinkSync(secondFileName); + }); + + afterEach(() => { + fs.unlinkSync(fileName); + }); +}); diff --git a/src/action.ts b/src/action.ts new file mode 100644 index 0000000..8e1b736 --- /dev/null +++ b/src/action.ts @@ -0,0 +1,121 @@ +// Copyright 2024 The Oppia Authors. All Rights Reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS-IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +/** + * @fileoverview Action implementation. + */ +import { error, info, debug } from '@actions/core'; + +import { Inputs } from './inputs'; +import { spawn } from 'child_process'; +import ms from 'milliseconds'; + +const OS = process.platform; + +function getExecutable(): string { + return OS === 'win32' ? 'powershell' : 'bash'; +} + +async function wait(ms: number) { + return new Promise((r) => setTimeout(r, ms)); +} + +interface CommandResult { + success: boolean; + exitCode: number; + output: string[]; +} + +async function runCommand(inputs: Inputs): Promise { + const endTime = Date.now() + ms.hours(5); + const executable = getExecutable(); + + // Timeout exit code - 124 + let exitCode = 124; + let done = false; + const output: string[] = []; + + debug(`Running command ${inputs.command} on ${OS} using shell ${executable}`); + const child = spawn(inputs.command, { shell: executable }); + + child.stdout?.on('data', (data) => { + process.stdout.write(data); + output.push(data); + }); + child.stderr?.on('data', (data) => { + process.stdout.write(data); + output.push(data); + }); + + child.on('exit', (code) => { + debug(`Code: ${code}`); + + if (code === null) { + error('exit code cannot be null'); + exitCode = 1; + return; + } + + exitCode = code; + done = true; + }); + + do { + const pollingPeriod = ms.seconds(1); + await wait(pollingPeriod); + } while (Date.now() < endTime && !done); + + return { + success: exitCode === 0, + exitCode, + output, + }; +} + +function hasFlakyOutput( + substrings_indicating_flaky_execution: string[], + output: string[] +): boolean { + const flakyIndicator = substrings_indicating_flaky_execution.find((flakyLine) => + output.some((outputLine) => outputLine.includes(flakyLine)) + ); + if (flakyIndicator === undefined) { + return false; + } + info(`Found flaky indicator: ${flakyIndicator}`); + return true; +} + +export async function runAction(inputs: Inputs): Promise { + for (let attempt = 1; attempt <= inputs.maxAttempts; attempt++) { + info(`Starting attempt #${attempt}`); + const { success, exitCode, output } = await runCommand(inputs); + if (success) { + info(`Attempt #${attempt} succeeded`); + return 0; + } + + info(`Attempt #${attempt} failed with exit code ${exitCode}`); + if (attempt == inputs.maxAttempts) { + return exitCode; + } + + if (!hasFlakyOutput(inputs.substringsIndicatingFlakyExecution, output)) { + info("Output doesn't contain flaky indicators, considering it a failure"); + return exitCode; + } + info('Output contains flaky indicators, restarting the test'); + } + throw new Error('Unreachable'); +} diff --git a/src/index.ts b/src/index.ts index aa5642e..c6d273d 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,187 +1,13 @@ -import { error, warning, info, debug, setOutput } from '@actions/core'; -import { execSync, spawn } from 'child_process'; -import ms from 'milliseconds'; -import kill from 'tree-kill'; +import { error } from '@actions/core'; -import { getInputs, getTimeout, Inputs, validateInputs } from './inputs'; -import { retryWait, wait } from './util'; - -const OS = process.platform; -const OUTPUT_TOTAL_ATTEMPTS_KEY = 'total_attempts'; -const OUTPUT_EXIT_CODE_KEY = 'exit_code'; -const OUTPUT_EXIT_ERROR_KEY = 'exit_error'; - -let exit: number; -let done: boolean; - -function getExecutable(inputs: Inputs): string { - if (!inputs.shell) { - return OS === 'win32' ? 'powershell' : 'bash'; - } - - let executable: string; - const shellName = inputs.shell.split(' ')[0]; - - switch (shellName) { - case 'bash': - case 'python': - case 'pwsh': { - executable = inputs.shell; - break; - } - case 'sh': { - if (OS === 'win32') { - throw new Error(`Shell ${shellName} not allowed on OS ${OS}`); - } - executable = inputs.shell; - break; - } - case 'cmd': - case 'powershell': { - if (OS !== 'win32') { - throw new Error(`Shell ${shellName} not allowed on OS ${OS}`); - } - executable = shellName + '.exe' + inputs.shell.replace(shellName, ''); - break; - } - default: { - throw new Error( - `Shell ${shellName} not supported. See https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idstepsshell for supported shells` - ); - } - } - return executable; -} - -async function runRetryCmd(inputs: Inputs): Promise { - // if no retry script, just continue - if (!inputs.on_retry_command) { - return; - } - - try { - await execSync(inputs.on_retry_command, { stdio: 'inherit' }); - // eslint-disable-next-line - } catch (error: any) { - info(`WARNING: Retry command threw the error ${error.message}`); - } -} - -async function runCmd(attempt: number, inputs: Inputs) { - const end_time = Date.now() + getTimeout(inputs); - const executable = getExecutable(inputs); - - exit = 0; - done = false; - let timeout = false; - - debug(`Running command ${inputs.command} on ${OS} using shell ${executable}`); - const child = - attempt > 1 && inputs.new_command_on_retry - ? spawn(inputs.new_command_on_retry, { shell: executable }) - : spawn(inputs.command, { shell: executable }); - - child.stdout?.on('data', (data) => { - process.stdout.write(data); - }); - child.stderr?.on('data', (data) => { - process.stdout.write(data); - }); - - child.on('exit', (code, signal) => { - debug(`Code: ${code}`); - debug(`Signal: ${signal}`); - - // timeouts are killed manually - if (signal === 'SIGTERM') { - return; - } - - // On Windows signal is null. - if (timeout) { - return; - } - - if (code && code > 0) { - exit = code; - } - - done = true; - }); - - do { - await wait(ms.seconds(inputs.polling_interval_seconds)); - } while (Date.now() < end_time && !done); - - if (!done && child.pid) { - timeout = true; - kill(child.pid); - await retryWait(ms.seconds(inputs.retry_wait_seconds)); - throw new Error(`Timeout of ${getTimeout(inputs)}ms hit`); - } else if (exit > 0) { - await retryWait(ms.seconds(inputs.retry_wait_seconds)); - throw new Error(`Child_process exited with error code ${exit}`); - } else { - return; - } -} - -async function runAction(inputs: Inputs) { - await validateInputs(inputs); - - for (let attempt = 1; attempt <= inputs.max_attempts; attempt++) { - try { - // just keep overwriting attempts output - setOutput(OUTPUT_TOTAL_ATTEMPTS_KEY, attempt); - await runCmd(attempt, inputs); - info(`Command completed after ${attempt} attempt(s).`); - break; - // eslint-disable-next-line - } catch (error: any) { - if (attempt === inputs.max_attempts) { - throw new Error(`Final attempt failed. ${error.message}`); - } else if (!done && inputs.retry_on === 'error') { - // error: timeout - throw error; - } else if (inputs.retry_on_exit_code && inputs.retry_on_exit_code !== exit) { - throw error; - } else if (exit > 0 && inputs.retry_on === 'timeout') { - // error: error - throw error; - } else { - await runRetryCmd(inputs); - if (inputs.warning_on_retry) { - warning(`Attempt ${attempt} failed. Reason: ${error.message}`); - } else { - info(`Attempt ${attempt} failed. Reason: ${error.message}`); - } - } - } - } -} +import { getInputs } from './inputs'; +import { runAction } from './action'; const inputs = getInputs(); runAction(inputs) - .then(() => { - setOutput(OUTPUT_EXIT_CODE_KEY, 0); - process.exit(0); // success - }) + .then((exitCode) => process.exit(exitCode)) .catch((err) => { - // exact error code if available, otherwise just 1 - const exitCode = exit > 0 ? exit : 1; - - if (inputs.continue_on_error) { - warning(err.message); - } else { - error(err.message); - } - - // these can be helpful to know if continue-on-error is true - setOutput(OUTPUT_EXIT_ERROR_KEY, err.message); - setOutput(OUTPUT_EXIT_CODE_KEY, exitCode); - - // if continue_on_error, exit with exact error code else exit gracefully - // mimics native continue-on-error that is not supported in composite actions - process.exit(inputs.continue_on_error ? 0 : exitCode); + error(`Failed test with exception ${err.message}`); + process.exit(1); }); diff --git a/src/inputs.ts b/src/inputs.ts index 20f318c..3677282 100644 --- a/src/inputs.ts +++ b/src/inputs.ts @@ -1,31 +1,15 @@ -import { getInput } from '@actions/core'; -import ms from 'milliseconds'; +import { getInput, getMultilineInput } from '@actions/core'; export interface Inputs { - timeout_minutes: number | undefined; - timeout_seconds: number | undefined; - max_attempts: number; + maxAttempts: number; command: string; - retry_wait_seconds: number; - shell: string | undefined; - polling_interval_seconds: number; - retry_on: string | undefined; - warning_on_retry: boolean; - on_retry_command: string | undefined; - continue_on_error: boolean; - new_command_on_retry: string | undefined; - retry_on_exit_code: number | undefined; + substringsIndicatingFlakyExecution: string[]; } -export function getInputNumber(id: string, required: boolean): number | undefined { - const input = getInput(id, { required }); +export function getInputNumber(id: string): number { + const input = getInput(id, { required: true }); const num = Number.parseInt(input); - // empty is ok - if (!input && !required) { - return; - } - if (!Number.isInteger(num)) { throw `Input ${id} only accepts numbers. Received ${input}`; } @@ -33,62 +17,16 @@ export function getInputNumber(id: string, required: boolean): number | undefine return num; } -export function getInputBoolean(id: string): boolean { - const input = getInput(id); - - if (!['true', 'false'].includes(input.toLowerCase())) { - throw `Input ${id} only accepts boolean values. Received ${input}`; - } - return input.toLowerCase() === 'true'; -} - -export async function validateInputs(inputs: Inputs) { - if ( - (!inputs.timeout_minutes && !inputs.timeout_seconds) || - (inputs.timeout_minutes && inputs.timeout_seconds) - ) { - throw new Error('Must specify either timeout_minutes or timeout_seconds inputs'); - } -} - -export function getTimeout(inputs: Inputs): number { - if (inputs.timeout_minutes) { - return ms.minutes(inputs.timeout_minutes); - } else if (inputs.timeout_seconds) { - return ms.seconds(inputs.timeout_seconds); - } - - throw new Error('Must specify either timeout_minutes or timeout_seconds inputs'); -} - export function getInputs(): Inputs { - const timeout_minutes = getInputNumber('timeout_minutes', false); - const timeout_seconds = getInputNumber('timeout_seconds', false); - const max_attempts = getInputNumber('max_attempts', true) || 3; + const max_attempts = getInputNumber('max_attempts'); const command = getInput('command', { required: true }); - const retry_wait_seconds = getInputNumber('retry_wait_seconds', false) || 10; - const shell = getInput('shell'); - const polling_interval_seconds = getInputNumber('polling_interval_seconds', false) || 1; - const retry_on = getInput('retry_on') || 'any'; - const warning_on_retry = getInput('warning_on_retry').toLowerCase() === 'true'; - const on_retry_command = getInput('on_retry_command'); - const continue_on_error = getInputBoolean('continue_on_error'); - const new_command_on_retry = getInput('new_command_on_retry'); - const retry_on_exit_code = getInputNumber('retry_on_exit_code', false); + const substringsIndicatingFlakyExecution = getMultilineInput( + 'substrings_indicating_flaky_execution' + ); return { - timeout_minutes, - timeout_seconds, - max_attempts, + maxAttempts: max_attempts, command, - retry_wait_seconds, - shell, - polling_interval_seconds, - retry_on, - warning_on_retry, - on_retry_command, - continue_on_error, - new_command_on_retry, - retry_on_exit_code, + substringsIndicatingFlakyExecution: substringsIndicatingFlakyExecution, }; } diff --git a/src/util.test.ts b/src/util.test.ts deleted file mode 100644 index 29559e5..0000000 --- a/src/util.test.ts +++ /dev/null @@ -1,22 +0,0 @@ -import 'jest'; -import { getHeapStatistics } from 'v8'; - -import { wait } from './util'; - -// otherwise, TypeError: Cannot assign to read only property 'performance' of object '[object global]' -Object.defineProperty(global, 'performance', { - writable: true, -}); - -// mocks the setTimeout function, see https://jestjs.io/docs/timer-mocks -jest.useFakeTimers(); -jest.spyOn(global, 'setTimeout'); - -describe('util', () => { - test('wait', async () => { - const waitTime = 1000; - wait(waitTime); - expect(setTimeout).toHaveBeenCalledTimes(1); - expect(setTimeout).toHaveBeenLastCalledWith(expect.any(Function), waitTime); - }); -}); diff --git a/src/util.ts b/src/util.ts deleted file mode 100644 index 47b6f96..0000000 --- a/src/util.ts +++ /dev/null @@ -1,12 +0,0 @@ -import { debug } from '@actions/core'; - -export async function wait(ms: number) { - return new Promise((r) => setTimeout(r, ms)); -} - -export async function retryWait(retryWaitSeconds: number) { - const waitStart = Date.now(); - await wait(retryWaitSeconds); - debug(`Waited ${Date.now() - waitStart}ms`); - debug(`Configured wait: ${retryWaitSeconds}ms`); -} diff --git a/test-data/large-output/Makefile b/test-data/large-output/Makefile deleted file mode 100644 index 2d15b4a..0000000 --- a/test-data/large-output/Makefile +++ /dev/null @@ -1,13 +0,0 @@ -SHELL = bash - -# this tests fix for the following issues -# https://github.com/nick-fields/retry/issues/76 -# https://github.com/nick-fields/retry/issues/84 - -bytes-%: - for i in {1..$*}; do cat kibibyte.txt; done; exit 2 -.PHONY: bytes-% - -lines-%: - for i in {1..$*}; do echo a; done; exit 2 -.PHONY: lines-% diff --git a/test-data/large-output/kibibyte.txt b/test-data/large-output/kibibyte.txt deleted file mode 100644 index 54960cb..0000000 --- a/test-data/large-output/kibibyte.txt +++ /dev/null @@ -1,13 +0,0 @@ -1: 0000 aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa -2: 0081 aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa -3: 0162 aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa -4: 243 aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa -5: 324 aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa -6: 405 aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa -7: 486 aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa -8: 567 aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa -9: 648 aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa -a: 729 aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa -b: 810 aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa -c: 891 aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa -d: 972 aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa \ No newline at end of file