Compare commits

...

62 Commits

Author SHA1 Message Date
Nick Fields
7f8f3d9f0f Merge pull request #50 from asnewman/new-command-on-retry-feature
Add new_command_on_retry
2021-12-09 20:05:34 -05:00
Nick Fields
bf1736e338 minor: regenerate dist 2021-12-09 20:02:53 -05:00
asnewman
f7cf641580 Add new_command_on_retry 2021-12-08 20:15:21 -08:00
Nick Fields
002ef572db Merge pull request #49 from lwhiteley/patch-1
fix: incorrect option for continue_on_error
2021-10-23 21:32:55 -04:00
Layton Whiteley
e80198a9da fix: incorrect option for continue_on_error
continue_on_error example was incorrect and if used would generate a warning.

change `continue-on-error` to `continue_on_error`
2021-10-20 16:50:07 +02:00
Nick Fields
c77dc43532 Merge pull request #48 from nick-invision/nrf/continue-on-error
Add continue_on_error action input
2021-10-06 20:02:05 -04:00
Nick Fields
0019811846 docs: update README with new input and usage 2021-09-23 22:36:47 -04:00
Nick Fields
a63662d5a7 patch: refresh from master 2021-09-23 22:29:08 -04:00
Nick Fields
67e1bdfd8d minor: add continue_on_error input option 2021-09-23 22:26:06 -04:00
Nick Fields
45ba062d35 Merge pull request #42 from nick-invision/nrf/add-multiline-example
Add multi-line example, consolidate dependabot maintenance bumps
2021-06-10 18:27:14 -04:00
Nick Fields
0470ad1628 patch: pull in open dependabot bumps 2021-06-10 18:08:08 -04:00
Nick Fields
2750220347 fix test 2021-06-10 17:55:53 -04:00
Nick Fields
b00fd808da add multi line example and test 2021-06-10 17:44:58 -04:00
Nick Fields
bebba89192 Merge pull request #40 from nick-invision/dependabot/npm_and_yarn/normalize-url-5.3.1
build(deps): bump normalize-url from 5.3.0 to 5.3.1
2021-06-08 21:12:49 -04:00
dependabot[bot]
52b3fdbcaa build(deps): bump normalize-url from 5.3.0 to 5.3.1
Bumps [normalize-url](https://github.com/sindresorhus/normalize-url) from 5.3.0 to 5.3.1.
- [Release notes](https://github.com/sindresorhus/normalize-url/releases)
- [Commits](https://github.com/sindresorhus/normalize-url/commits)

---
updated-dependencies:
- dependency-name: normalize-url
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2021-06-09 00:51:44 +00:00
dependabot[bot]
f3f0bb1a3c build(deps): bump hosted-git-info from 2.8.5 to 2.8.9 (#39)
Bumps [hosted-git-info](https://github.com/npm/hosted-git-info) from 2.8.5 to 2.8.9.
- [Release notes](https://github.com/npm/hosted-git-info/releases)
- [Changelog](https://github.com/npm/hosted-git-info/blob/v2.8.9/CHANGELOG.md)
- [Commits](https://github.com/npm/hosted-git-info/compare/v2.8.5...v2.8.9)

Signed-off-by: dependabot[bot] <support@github.com>

Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2021-05-11 10:32:50 -04:00
Nick Fields
7c68161adf Add on_retry_command input to optionally run cmd before a retry (#33)
* minor: add on_retry_command input to optionally run cmd before a retry

* test: add test for on-retry-command failure
2021-01-04 21:32:32 -05:00
Nick Fields
025c480d85 Merge pull request #32 from nick-invision/dependabot/npm_and_yarn/lodash-4.17.20
build(deps): bump lodash from 4.17.15 to 4.17.20
2021-01-03 21:35:32 -05:00
Nick Fields
3073a9f1e1 Merge pull request #29 from nick-invision/dependabot/npm_and_yarn/ini-1.3.8
build(deps): bump ini from 1.3.5 to 1.3.8
2021-01-03 21:28:10 -05:00
dependabot[bot]
9e6dab8302 build(deps): bump lodash from 4.17.15 to 4.17.20
Bumps [lodash](https://github.com/lodash/lodash) from 4.17.15 to 4.17.20.
- [Release notes](https://github.com/lodash/lodash/releases)
- [Commits](https://github.com/lodash/lodash/compare/4.17.15...4.17.20)

Signed-off-by: dependabot[bot] <support@github.com>
2021-01-04 02:27:53 +00:00
Nick Fields
850bd83fba More doc cleanup 2021-01-03 21:16:16 -05:00
Nick Fields
88ed4273a8 Merge pull request #31 from nick-invision/fix-docs
docs: cleanup docs around shell defaults and supported
2021-01-03 21:09:04 -05:00
Nick Fields
bee86ddb77 docs: cleanup docs around shell defaults and supported 2021-01-03 21:00:31 -05:00
Nick Fields
f865f2ade8 test: fix tests again 2021-01-02 15:34:51 -05:00
Nick Fields
8310ca5ae8 test: fix tests 2021-01-02 15:29:38 -05:00
Nick Fields
e48877fb9c Merge pull request #30 from isaacrlevin/master
feat: add SHELL input support
2021-01-02 15:25:40 -05:00
Isaac Levin
4af9664183 Merge pull request #1 from nick-invision/nrf/shell
Don't require OS input and use correct default shell per os
2021-01-02 08:55:04 -08:00
Nick Fields
d0aac3501c fix: dont require OS input and use correct shell per os 2021-01-02 10:20:16 -05:00
Isaac Levin
877a0ac37e feat: add SHELL input support 2021-01-01 22:57:53 +00:00
dependabot[bot]
7463808b4e build(deps): bump ini from 1.3.5 to 1.3.8
Bumps [ini](https://github.com/isaacs/ini) from 1.3.5 to 1.3.8.
- [Release notes](https://github.com/isaacs/ini/releases)
- [Commits](https://github.com/isaacs/ini/compare/v1.3.5...v1.3.8)

Signed-off-by: dependabot[bot] <support@github.com>
2020-12-12 07:53:24 +00:00
Nick Fields
0aeb89504c Merge pull request #27 from nick-invision/dependabot/npm_and_yarn/semantic-release-17.2.3
build(deps-dev): bump semantic-release from 17.2.2 to 17.2.3
2020-11-19 17:37:43 -05:00
dependabot[bot]
b2ee390b23 build(deps-dev): bump semantic-release from 17.2.2 to 17.2.3
Bumps [semantic-release](https://github.com/semantic-release/semantic-release) from 17.2.2 to 17.2.3.
- [Release notes](https://github.com/semantic-release/semantic-release/releases)
- [Commits](https://github.com/semantic-release/semantic-release/compare/v17.2.2...v17.2.3)

Signed-off-by: dependabot[bot] <support@github.com>
2020-11-18 22:35:27 +00:00
Nick Fields
fb3bca3fb5 Merge pull request #26 from nick-invision/issues-24-25
Add option to suppress warning on retry and fix timeout bug
2020-11-18 10:45:31 -05:00
Nick Fields
51e29ff1ae minor: document timeout_seconds input 2020-11-18 10:37:56 -05:00
Nick Fields
292d515fa9 fix: allow timeout_seconds to be less than retry_wait_time 2020-11-18 10:28:44 -05:00
Nick Fields
5ee366655c feat: add warning_on_retry input 2020-11-18 10:25:11 -05:00
Nick Fields
0bbc6bd3b0 Merge pull request #23 from nick-invision/nrf/typescript-conv
minor: migrate to typescript and updated devDeps
2020-11-14 11:54:06 -05:00
Nick Fields
409054c003 minor: migrate to typescript and updated devDeps 2020-11-14 11:45:32 -05:00
Nick Fields
02159d7095 Merge pull request #13 from nick-invision/dependabot/npm_and_yarn/node-fetch-2.6.1
build(deps): bump node-fetch from 2.6.0 to 2.6.1
2020-11-14 10:19:03 -05:00
Nick Fields
c36bd33fae Merge pull request #20 from nick-invision/snyk-fix-2977ebb55dda5c312597a1469964ea24
[Snyk] Security upgrade @actions/core from 1.2.4 to 1.2.6
2020-11-14 09:58:58 -05:00
Nick Fields
ad6c447324 Merge pull request #22 from nick-invision/nrf/add-logging-test
Fix command output
2020-11-14 09:57:12 -05:00
Nick Fields
36c6f604ab fix: handle errors properly 2020-11-14 09:11:33 -05:00
Nick Fields
31e0097983 fix: make command spawnable to fix log issue 2020-10-31 10:43:28 -04:00
Nick Fields
7a4513731b test: add timeout_minutes 2020-10-30 19:57:42 -04:00
Nick Fields
0a47821646 test: add log example to ci workflow 2020-10-30 19:49:12 -04:00
Nick Fields
193acc1924 Update issue templates 2020-10-30 19:44:01 -04:00
snyk-bot
8965a748e1 fix: package.json & package-lock.json to reduce vulnerabilities
The following vulnerabilities are fixed with an upgrade:
- https://snyk.io/vuln/SNYK-JS-ACTIONSCORE-1015402
2020-10-03 00:51:03 +00:00
Nick Fields
c803451cc1 Merge pull request #17 from nick-invision/pr-15
Add retry_on option
2020-09-29 15:08:35 -04:00
Nick Fields
3f5463b526 major: bump to v2 and added lots of examples 2020-09-29 14:56:52 -04:00
Nick Fields
915303cda5 fix: surface exit code from spawned process
patch: added dotenv sample configuration and command to run locally

fix: added timeout_seconds input and handle timeout properly
2020-09-29 14:22:46 -04:00
Nick Fields
86ecaf34fa fix: action.yml misspelling 2020-09-29 10:49:30 -04:00
Nick Fields
ec785f59e1 minor: added tests and helper outputs for PR #15 2020-09-29 10:48:02 -04:00
milahu
d2b20569e3 add option retry_on 2020-09-21 20:47:01 +02:00
dependabot[bot]
dea74f0715 build(deps): bump node-fetch from 2.6.0 to 2.6.1
Bumps [node-fetch](https://github.com/bitinn/node-fetch) from 2.6.0 to 2.6.1.
- [Release notes](https://github.com/bitinn/node-fetch/releases)
- [Changelog](https://github.com/node-fetch/node-fetch/blob/master/docs/CHANGELOG.md)
- [Commits](https://github.com/bitinn/node-fetch/compare/v2.6.0...v2.6.1)

Signed-off-by: dependabot[bot] <support@github.com>
2020-09-12 15:04:35 +00:00
Nick Fields
7841cadab1 Updated doc to reflect NodeJS requirement
This was identified by issue #12
2020-09-03 10:05:41 -04:00
Nick Fields
87ec0a8a32 Merge pull request #7 from nick-invision/dependabot/npm_and_yarn/npm-6.14.6
build(deps): bump npm from 6.13.7 to 6.14.6
2020-08-23 10:46:34 -04:00
dependabot[bot]
fc84966019 build(deps): bump npm from 6.13.7 to 6.14.6
Bumps [npm](https://github.com/npm/cli) from 6.13.7 to 6.14.6.
- [Release notes](https://github.com/npm/cli/releases)
- [Changelog](https://github.com/npm/cli/blob/latest/CHANGELOG.md)
- [Commits](https://github.com/npm/cli/compare/v6.13.7...v6.14.6)

Signed-off-by: dependabot[bot] <support@github.com>
2020-07-07 20:57:13 +00:00
Nick Fields
39da88d5f7 Merge pull request #6 from nick-invision/nrf/issue-5
Enforce retry_wait_seconds both when command fails and times out
2020-06-17 14:09:20 -04:00
Nick Fields
6a380b501f fix: fixed debug logging 2020-06-17 13:57:10 -04:00
Nick Fields
3ded872743 fix: enforce RETRY_WAIT_SECONDS on both command timeout and error 2020-06-17 13:52:49 -04:00
Nick Fields
88ea919f23 patch: added debugging for issue #5 2020-06-17 13:48:21 -04:00
Nick Fields
21d303ab46 fix: fix tag push in publish step 2020-06-17 13:18:10 -04:00
16 changed files with 3192 additions and 1574 deletions

20
.github/ISSUE_TEMPLATE/bug-report.md vendored Normal file
View File

@@ -0,0 +1,20 @@
---
name: Bug report
about: Create a report to help us improve
title: ''
labels: ''
assignees: nick-invision
---
**Describe the bug**
A clear and concise description of what the bug is, **including the snippet from your workflow `yaml` showing your configuration and command being executed.**
**Expected behavior**
A clear and concise description of what you expected to happen.
**Screenshots**
If applicable, add screenshots to help explain your problem.
**Logs**
Enable [debug logging](https://docs.github.com/en/free-pro-team@latest/actions/managing-workflow-runs/enabling-debug-logging#enabling-step-debug-logging) then attach the [raw logs](https://docs.github.com/en/free-pro-team@latest/actions/managing-workflow-runs/using-workflow-run-logs#downloading-logs) (specifically the raw output of this action).

5
.github/scripts/log-examples.js vendored Normal file
View File

@@ -0,0 +1,5 @@
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');

View File

@@ -18,33 +18,317 @@ jobs:
node-version: 12
- name: Install dependencies
run: npm ci
- name: Test
uses: ./
continue-on-error: true
with:
timeout_minutes: 1
max_attempts: 3
command: npm install this-isnt-a-real-package-name-zzz
- 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 (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
- 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')"
- 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: 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 }}
- 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 }}
# timeout tests (takes longer to run so run last)
- 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 }}
- 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 }}
- 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 }}
- 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.outputs.total_attempts }}
- uses: nick-invision/assert-action@v1
with:
expected: failure
actual: ${{ steps.sad_path_timeout.outcome }}
- 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_windows:
name: Run Windows Tests
if: startsWith(github.ref, 'refs/heads')
runs-on: windows-latest
steps:
- name: Checkout
uses: actions/checkout@v2
- name: Setup Node.js
uses: actions/setup-node@v1
with:
node-version: 12
- 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"
# runs on push to master only
cd:
@@ -67,7 +351,7 @@ jobs:
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Tag
run: git tag -f v${MAJOR_VERSION} && git push origin v${MAJOR_VERSION}
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 }}

View File

@@ -8,6 +8,7 @@ module.exports = {
{ type: 'minor', release: 'minor' },
{ type: 'major', release: 'major' },
{ type: 'patch', release: 'patch' },
{ type: 'test', release: false },
{ scope: 'no-release', release: false },
],
},

199
README.md
View File

@@ -6,7 +6,11 @@ Retries an Action step on failure or timeout. This is currently intended to repl
### `timeout_minutes`
**Required** Minutes to wait before attempt times out
**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`
@@ -20,16 +24,203 @@ Retries an Action step on failure or timeout. This is currently intended to repl
**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/free-pro-team@latest/actions/reference/workflow-syntax-for-github-actions#using-a-specific-shell)
### `polling_interval_seconds`
**Optional** Number of seconds to wait while polling for command result. Defaults to `1`
## Example usage
### `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`
## 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
## Examples
### Shell
```yaml
uses: nick-invision/retry@v1
uses: nick-invision/retry@v2
with:
timeout_minutes: 10
max_attempts: 3
command: npm install
shell: pwsh
command: dir
```
### Timeout in minutes
```yaml
uses: nick-invision/retry@v2
with:
timeout_minutes: 10
max_attempts: 3
command: npm run some-typically-slow-script
```
### Timeout in seconds
```yaml
uses: nick-invision/retry@v2
with:
timeout_seconds: 15
max_attempts: 3
command: npm run some-typically-fast-script
```
### Only retry after timeout
```yaml
uses: nick-invision/retry@v2
with:
timeout_seconds: 15
max_attempts: 3
retry_on: timeout
command: npm run some-typically-fast-script
```
### Only retry after error
```yaml
uses: nick-invision/retry@v2
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-invision/retry@v2
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-invision/assert-action@v1
with:
expected: success
actual: ${{ steps.retry.outcome }}
- name: Assert that action exited with expected exit code
uses: nick-invision/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-invision/retry@v2
id: retry
# see https://docs.github.com/en/free-pro-team@latest/actions/reference/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-invision/assert-action@v1
with:
expected: failure
actual: ${{ steps.retry.outcome }}
- name: Assert that action exited with expected exit code
uses: nick-invision/assert-action@v1
with:
expected: 99
actual: ${{ steps.retry.outputs.exit_code }}
- name: Assert that action made expected number of attempts
uses: nick-invision/assert-action@v1
with:
expected: 3
actual: ${{ steps.retry.outputs.total_attempts }}
```
### Run script after failure but before retry
```yaml
uses: nick-invision/retry@v2
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-invision/retry@v2
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"
```
## 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.

View File

@@ -2,8 +2,11 @@ name: Retry Step
description: 'Retry a step on failure or timeout'
inputs:
timeout_minutes:
description: Minutes to wait before attempt times out
required: true
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
@@ -15,10 +18,34 @@ inputs:
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
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: 'node12'
main: 'dist/index.js'
main: 'dist/index.js'

8
dist/exec.js vendored
View File

@@ -1,8 +0,0 @@
const { execSync } = require('child_process');
const COMMAND = process.argv.splice(2)[0];
function run() {
execSync(COMMAND, { stdio: 'inherit' });
}
run();

721
dist/index.js vendored
View File

@@ -34,7 +34,7 @@ module.exports =
/******/ // the startup function
/******/ function startup() {
/******/ // Load entry module and return exports
/******/ return __webpack_require__(676);
/******/ return __webpack_require__(325);
/******/ };
/******/
/******/ // run startup
@@ -43,6 +43,52 @@ module.exports =
/************************************************************************/
/******/ ({
/***/ 82:
/***/ (function(__unusedmodule, exports) {
"use strict";
// We use any as a valid input type
/* eslint-disable @typescript-eslint/no-explicit-any */
Object.defineProperty(exports, "__esModule", { value: true });
exports.toCommandProperties = exports.toCommandValue = void 0;
/**
* Sanitizes an input into a string so it can be passed into issueCommand safely
* @param input input to sanitize into a string
*/
function toCommandValue(input) {
if (input === null || input === undefined) {
return '';
}
else if (typeof input === 'string' || input instanceof String) {
return input;
}
return JSON.stringify(input);
}
exports.toCommandValue = toCommandValue;
/**
*
* @param annotationProperties
* @returns The command properties to send with the actual annotation command
* See IssueCommandProperties: https://github.com/actions/runner/blob/main/src/Runner.Worker/ActionCommandManager.cs#L646
*/
function toCommandProperties(annotationProperties) {
if (!Object.keys(annotationProperties).length) {
return {};
}
return {
title: annotationProperties.title,
line: annotationProperties.startLine,
endLine: annotationProperties.endLine,
col: annotationProperties.startColumn,
endColumn: annotationProperties.endColumn
};
}
exports.toCommandProperties = toCommandProperties;
//# sourceMappingURL=utils.js.map
/***/ }),
/***/ 87:
/***/ (function(module) {
@@ -50,6 +96,55 @@ module.exports = require("os");
/***/ }),
/***/ 102:
/***/ (function(__unusedmodule, exports, __webpack_require__) {
"use strict";
// For internal use, subject to change.
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
Object.defineProperty(o, k2, { enumerable: true, get: function() { return m[k]; } });
}) : (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
o[k2] = m[k];
}));
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
Object.defineProperty(o, "default", { enumerable: true, value: v });
}) : function(o, v) {
o["default"] = v;
});
var __importStar = (this && this.__importStar) || function (mod) {
if (mod && mod.__esModule) return mod;
var result = {};
if (mod != null) for (var k in mod) if (k !== "default" && Object.hasOwnProperty.call(mod, k)) __createBinding(result, mod, k);
__setModuleDefault(result, mod);
return result;
};
Object.defineProperty(exports, "__esModule", { value: true });
exports.issueCommand = void 0;
// We use any as a valid input type
/* eslint-disable @typescript-eslint/no-explicit-any */
const fs = __importStar(__webpack_require__(747));
const os = __importStar(__webpack_require__(87));
const utils_1 = __webpack_require__(82);
function issueCommand(command, message) {
const filePath = process.env[`GITHUB_${command}`];
if (!filePath) {
throw new Error(`Unable to find environment variable for file command ${command}`);
}
if (!fs.existsSync(filePath)) {
throw new Error(`Missing file at path: ${filePath}`);
}
fs.appendFileSync(filePath, `${utils_1.toCommandValue(message)}${os.EOL}`, {
encoding: 'utf8'
});
}
exports.issueCommand = issueCommand;
//# sourceMappingURL=file-command.js.map
/***/ }),
/***/ 129:
/***/ (function(module) {
@@ -74,6 +169,381 @@ module.exports = {
};
/***/ }),
/***/ 322:
/***/ (function(__unusedmodule, exports) {
"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 (_) 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.wait = void 0;
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;
/***/ }),
/***/ 325:
/***/ (function(__unusedmodule, exports, __webpack_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 (_) 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 });
var core_1 = __webpack_require__(470);
var child_process_1 = __webpack_require__(129);
var milliseconds_1 = __importDefault(__webpack_require__(156));
var tree_kill_1 = __importDefault(__webpack_require__(791));
var util_1 = __webpack_require__(322);
// inputs
var TIMEOUT_MINUTES = getInputNumber('timeout_minutes', false);
var TIMEOUT_SECONDS = getInputNumber('timeout_seconds', false);
var MAX_ATTEMPTS = getInputNumber('max_attempts', true) || 3;
var COMMAND = core_1.getInput('command', { required: true });
var RETRY_WAIT_SECONDS = getInputNumber('retry_wait_seconds', false) || 10;
var SHELL = core_1.getInput('shell');
var POLLING_INTERVAL_SECONDS = getInputNumber('polling_interval_seconds', false) || 1;
var RETRY_ON = core_1.getInput('retry_on') || 'any';
var WARNING_ON_RETRY = core_1.getInput('warning_on_retry').toLowerCase() === 'true';
var ON_RETRY_COMMAND = core_1.getInput('on_retry_command');
var CONTINUE_ON_ERROR = getInputBoolean('continue_on_error');
var NEW_COMMAND_ON_RETRY = core_1.getInput('new_command_on_retry');
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 getInputNumber(id, required) {
var input = core_1.getInput(id, { required: required });
var num = Number.parseInt(input);
// empty is ok
if (!input && !required) {
return;
}
if (!Number.isInteger(num)) {
throw "Input " + id + " only accepts numbers. Received " + input;
}
return num;
}
function getInputBoolean(id) {
var input = core_1.getInput(id);
if (!['true', 'false'].includes(input.toLowerCase())) {
throw "Input " + id + " only accepts boolean values. Received " + input;
}
return input.toLowerCase() === 'true';
}
function retryWait() {
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*/, util_1.wait(milliseconds_1.default.seconds(RETRY_WAIT_SECONDS))];
case 1:
_a.sent();
core_1.debug("Waited " + (Date.now() - waitStart) + "ms");
core_1.debug("Configured wait: " + milliseconds_1.default.seconds(RETRY_WAIT_SECONDS) + "ms");
return [2 /*return*/];
}
});
});
}
function validateInputs() {
return __awaiter(this, void 0, void 0, function () {
return __generator(this, function (_a) {
if ((!TIMEOUT_MINUTES && !TIMEOUT_SECONDS) || (TIMEOUT_MINUTES && TIMEOUT_SECONDS)) {
throw new Error('Must specify either timeout_minutes or timeout_seconds inputs');
}
return [2 /*return*/];
});
});
}
function getTimeout() {
if (TIMEOUT_MINUTES) {
return milliseconds_1.default.minutes(TIMEOUT_MINUTES);
}
else if (TIMEOUT_SECONDS) {
return milliseconds_1.default.seconds(TIMEOUT_SECONDS);
}
throw new Error('Must specify either timeout_minutes or timeout_seconds inputs');
}
function getExecutable() {
if (!SHELL) {
return OS === 'win32' ? 'powershell' : 'bash';
}
var executable;
switch (SHELL) {
case "bash":
case "python":
case "pwsh": {
executable = SHELL;
break;
}
case "sh": {
if (OS === 'win32') {
throw new Error("Shell " + SHELL + " not allowed on OS " + OS);
}
executable = SHELL;
break;
}
case "cmd":
case "powershell": {
if (OS !== 'win32') {
throw new Error("Shell " + SHELL + " not allowed on OS " + OS);
}
executable = SHELL + ".exe";
break;
}
default: {
throw new Error("Shell " + SHELL + " not supported. See https://docs.github.com/en/free-pro-team@latest/actions/reference/workflow-syntax-for-github-actions#using-a-specific-shell for supported shells");
}
}
return executable;
}
function runRetryCmd() {
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 (!ON_RETRY_COMMAND) {
return [2 /*return*/];
}
_a.label = 1;
case 1:
_a.trys.push([1, 3, , 4]);
return [4 /*yield*/, child_process_1.execSync(ON_RETRY_COMMAND, { stdio: 'inherit' })];
case 2:
_a.sent();
return [3 /*break*/, 4];
case 3:
error_1 = _a.sent();
core_1.info("WARNING: Retry command threw the error " + error_1.message);
return [3 /*break*/, 4];
case 4: return [2 /*return*/];
}
});
});
}
function runCmd(attempt) {
var _a, _b;
return __awaiter(this, void 0, void 0, function () {
var end_time, executable, child;
return __generator(this, function (_c) {
switch (_c.label) {
case 0:
end_time = Date.now() + getTimeout();
executable = getExecutable();
exit = 0;
done = false;
core_1.debug("Running command " + COMMAND + " on " + OS + " using shell " + executable);
child = attempt > 1 && NEW_COMMAND_ON_RETRY
? child_process_1.exec(NEW_COMMAND_ON_RETRY, { 'shell': executable })
: child_process_1.exec(COMMAND, { 'shell': executable });
(_a = child.stdout) === null || _a === void 0 ? void 0 : _a.on('data', function (data) {
process.stdout.write(data);
});
(_b = child.stderr) === null || _b === void 0 ? void 0 : _b.on('data', function (data) {
process.stdout.write(data);
});
child.on('exit', function (code, signal) {
core_1.debug("Code: " + code);
core_1.debug("Signal: " + signal);
if (code && code > 0) {
exit = code;
}
// timeouts are killed manually
if (signal === 'SIGTERM') {
return;
}
done = true;
});
_c.label = 1;
case 1: return [4 /*yield*/, util_1.wait(milliseconds_1.default.seconds(POLLING_INTERVAL_SECONDS))];
case 2:
_c.sent();
_c.label = 3;
case 3:
if (Date.now() < end_time && !done) return [3 /*break*/, 1];
_c.label = 4;
case 4:
if (!!done) return [3 /*break*/, 6];
tree_kill_1.default(child.pid);
return [4 /*yield*/, retryWait()];
case 5:
_c.sent();
throw new Error("Timeout of " + getTimeout() + "ms hit");
case 6:
if (!(exit > 0)) return [3 /*break*/, 8];
return [4 /*yield*/, retryWait()];
case 7:
_c.sent();
throw new Error("Child_process exited with error code " + exit);
case 8: return [2 /*return*/];
}
});
});
}
function runAction() {
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*/, validateInputs()];
case 1:
_a.sent();
attempt = 1;
_a.label = 2;
case 2:
if (!(attempt <= MAX_ATTEMPTS)) return [3 /*break*/, 12];
_a.label = 3;
case 3:
_a.trys.push([3, 5, , 11]);
// just keep overwriting attempts output
core_1.setOutput(OUTPUT_TOTAL_ATTEMPTS_KEY, attempt);
return [4 /*yield*/, runCmd(attempt)];
case 4:
_a.sent();
core_1.info("Command completed after " + attempt + " attempt(s).");
return [3 /*break*/, 12];
case 5:
error_2 = _a.sent();
if (!(attempt === MAX_ATTEMPTS)) return [3 /*break*/, 6];
throw new Error("Final attempt failed. " + error_2.message);
case 6:
if (!(!done && RETRY_ON === 'error')) return [3 /*break*/, 7];
// error: timeout
throw error_2;
case 7:
if (!(exit > 0 && RETRY_ON === 'timeout')) return [3 /*break*/, 8];
// error: error
throw error_2;
case 8: return [4 /*yield*/, runRetryCmd()];
case 9:
_a.sent();
if (WARNING_ON_RETRY) {
core_1.warning("Attempt " + attempt + " failed. Reason: " + error_2.message);
}
else {
core_1.info("Attempt " + attempt + " failed. Reason: " + error_2.message);
}
_a.label = 10;
case 10: return [3 /*break*/, 11];
case 11:
attempt++;
return [3 /*break*/, 2];
case 12: return [2 /*return*/];
}
});
});
}
runAction()
.then(function () {
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 (CONTINUE_ON_ERROR) {
core_1.warning(err.message);
}
else {
core_1.error(err.message);
}
// these can be helpful to know if continue-on-error is true
core_1.setOutput(OUTPUT_EXIT_ERROR_KEY, err.message);
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(CONTINUE_ON_ERROR ? 0 : exitCode);
});
/***/ }),
/***/ 431:
@@ -81,15 +551,29 @@ module.exports = {
"use strict";
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
Object.defineProperty(o, k2, { enumerable: true, get: function() { return m[k]; } });
}) : (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
o[k2] = m[k];
}));
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
Object.defineProperty(o, "default", { enumerable: true, value: v });
}) : function(o, v) {
o["default"] = v;
});
var __importStar = (this && this.__importStar) || function (mod) {
if (mod && mod.__esModule) return mod;
var result = {};
if (mod != null) for (var k in mod) if (Object.hasOwnProperty.call(mod, k)) result[k] = mod[k];
result["default"] = mod;
if (mod != null) for (var k in mod) if (k !== "default" && Object.hasOwnProperty.call(mod, k)) __createBinding(result, mod, k);
__setModuleDefault(result, mod);
return result;
};
Object.defineProperty(exports, "__esModule", { value: true });
exports.issue = exports.issueCommand = void 0;
const os = __importStar(__webpack_require__(87));
const utils_1 = __webpack_require__(82);
/**
* Commands
*
@@ -144,13 +628,13 @@ class Command {
}
}
function escapeData(s) {
return (s || '')
return utils_1.toCommandValue(s)
.replace(/%/g, '%25')
.replace(/\r/g, '%0D')
.replace(/\n/g, '%0A');
}
function escapeProperty(s) {
return (s || '')
return utils_1.toCommandValue(s)
.replace(/%/g, '%25')
.replace(/\r/g, '%0D')
.replace(/\n/g, '%0A')
@@ -166,6 +650,25 @@ function escapeProperty(s) {
"use strict";
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
Object.defineProperty(o, k2, { enumerable: true, get: function() { return m[k]; } });
}) : (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
o[k2] = m[k];
}));
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
Object.defineProperty(o, "default", { enumerable: true, value: v });
}) : function(o, v) {
o["default"] = v;
});
var __importStar = (this && this.__importStar) || function (mod) {
if (mod && mod.__esModule) return mod;
var result = {};
if (mod != null) for (var k in mod) if (k !== "default" && Object.hasOwnProperty.call(mod, k)) __createBinding(result, mod, k);
__setModuleDefault(result, mod);
return result;
};
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) {
@@ -175,15 +678,11 @@ var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, ge
step((generator = generator.apply(thisArg, _arguments || [])).next());
});
};
var __importStar = (this && this.__importStar) || function (mod) {
if (mod && mod.__esModule) return mod;
var result = {};
if (mod != null) for (var k in mod) if (Object.hasOwnProperty.call(mod, k)) result[k] = mod[k];
result["default"] = mod;
return result;
};
Object.defineProperty(exports, "__esModule", { value: true });
exports.getState = exports.saveState = exports.group = exports.endGroup = exports.startGroup = exports.info = exports.notice = exports.warning = exports.error = exports.debug = exports.isDebug = exports.setFailed = exports.setCommandEcho = exports.setOutput = exports.getBooleanInput = exports.getMultilineInput = exports.getInput = exports.addPath = exports.setSecret = exports.exportVariable = exports.ExitCode = void 0;
const command_1 = __webpack_require__(431);
const file_command_1 = __webpack_require__(102);
const utils_1 = __webpack_require__(82);
const os = __importStar(__webpack_require__(87));
const path = __importStar(__webpack_require__(622));
/**
@@ -206,11 +705,21 @@ var ExitCode;
/**
* Sets env variable for this action and future actions in the job
* @param name the name of the variable to set
* @param val the value of the variable
* @param val the value of the variable. Non-string values will be converted to a string via JSON.stringify
*/
// eslint-disable-next-line @typescript-eslint/no-explicit-any
function exportVariable(name, val) {
process.env[name] = val;
command_1.issueCommand('set-env', { name }, val);
const convertedVal = utils_1.toCommandValue(val);
process.env[name] = convertedVal;
const filePath = process.env['GITHUB_ENV'] || '';
if (filePath) {
const delimiter = '_GitHubActionsFileCommandDelimeter_';
const commandValue = `${name}<<${delimiter}${os.EOL}${convertedVal}${os.EOL}${delimiter}`;
file_command_1.issueCommand('ENV', commandValue);
}
else {
command_1.issueCommand('set-env', { name }, convertedVal);
}
}
exports.exportVariable = exportVariable;
/**
@@ -226,12 +735,20 @@ exports.setSecret = setSecret;
* @param inputPath
*/
function addPath(inputPath) {
command_1.issueCommand('add-path', {}, inputPath);
const filePath = process.env['GITHUB_PATH'] || '';
if (filePath) {
file_command_1.issueCommand('PATH', inputPath);
}
else {
command_1.issueCommand('add-path', {}, inputPath);
}
process.env['PATH'] = `${inputPath}${path.delimiter}${process.env['PATH']}`;
}
exports.addPath = addPath;
/**
* Gets the value of an input. The value is also trimmed.
* Gets the value of an input.
* Unless trimWhitespace is set to false in InputOptions, the value is also trimmed.
* Returns an empty string if the value is not defined.
*
* @param name name of the input to get
* @param options optional. See InputOptions.
@@ -242,19 +759,70 @@ function getInput(name, options) {
if (options && options.required && !val) {
throw new Error(`Input required and not supplied: ${name}`);
}
if (options && options.trimWhitespace === false) {
return val;
}
return val.trim();
}
exports.getInput = getInput;
/**
* Gets the values of an multiline input. Each value is also trimmed.
*
* @param name name of the input to get
* @param options optional. See InputOptions.
* @returns string[]
*
*/
function getMultilineInput(name, options) {
const inputs = getInput(name, options)
.split('\n')
.filter(x => x !== '');
return inputs;
}
exports.getMultilineInput = getMultilineInput;
/**
* Gets the input value of the boolean type in the YAML 1.2 "core schema" specification.
* Support boolean input list: `true | True | TRUE | false | False | FALSE` .
* The return value is also in boolean type.
* ref: https://yaml.org/spec/1.2/spec.html#id2804923
*
* @param name name of the input to get
* @param options optional. See InputOptions.
* @returns boolean
*/
function getBooleanInput(name, options) {
const trueValue = ['true', 'True', 'TRUE'];
const falseValue = ['false', 'False', 'FALSE'];
const val = getInput(name, options);
if (trueValue.includes(val))
return true;
if (falseValue.includes(val))
return false;
throw new TypeError(`Input does not meet YAML 1.2 "Core Schema" specification: ${name}\n` +
`Support boolean input list: \`true | True | TRUE | false | False | FALSE\``);
}
exports.getBooleanInput = getBooleanInput;
/**
* Sets the value of an output.
*
* @param name name of the output to set
* @param value value to store
* @param value value to store. Non-string values will be converted to a string via JSON.stringify
*/
// eslint-disable-next-line @typescript-eslint/no-explicit-any
function setOutput(name, value) {
process.stdout.write(os.EOL);
command_1.issueCommand('set-output', { name }, value);
}
exports.setOutput = setOutput;
/**
* Enables or disables the echoing of commands into stdout for the rest of the step.
* Echoing is disabled by default if ACTIONS_STEP_DEBUG is not set.
*
*/
function setCommandEcho(enabled) {
command_1.issue('echo', enabled ? 'on' : 'off');
}
exports.setCommandEcho = setCommandEcho;
//-----------------------------------------------------------------------
// Results
//-----------------------------------------------------------------------
@@ -271,6 +839,13 @@ exports.setFailed = setFailed;
//-----------------------------------------------------------------------
// Logging Commands
//-----------------------------------------------------------------------
/**
* Gets whether Actions Step Debug is on or not
*/
function isDebug() {
return process.env['RUNNER_DEBUG'] === '1';
}
exports.isDebug = isDebug;
/**
* Writes debug message to user log
* @param message debug message
@@ -281,20 +856,31 @@ function debug(message) {
exports.debug = debug;
/**
* Adds an error issue
* @param message error issue message
* @param message error issue message. Errors will be converted to string via toString()
* @param properties optional properties to add to the annotation.
*/
function error(message) {
command_1.issue('error', message);
function error(message, properties = {}) {
command_1.issueCommand('error', utils_1.toCommandProperties(properties), message instanceof Error ? message.toString() : message);
}
exports.error = error;
/**
* Adds an warning issue
* @param message warning issue message
* Adds a warning issue
* @param message warning issue message. Errors will be converted to string via toString()
* @param properties optional properties to add to the annotation.
*/
function warning(message) {
command_1.issue('warning', message);
function warning(message, properties = {}) {
command_1.issueCommand('warning', utils_1.toCommandProperties(properties), message instanceof Error ? message.toString() : message);
}
exports.warning = warning;
/**
* Adds a notice issue
* @param message notice issue message. Errors will be converted to string via toString()
* @param properties optional properties to add to the annotation.
*/
function notice(message, properties = {}) {
command_1.issueCommand('notice', utils_1.toCommandProperties(properties), message instanceof Error ? message.toString() : message);
}
exports.notice = notice;
/**
* Writes info to log with console.log.
* @param message info message
@@ -350,8 +936,9 @@ exports.group = group;
* Saves state for current action, the state can only be retrieved by this action's post job execution.
*
* @param name name of the state to store
* @param value value to store
* @param value value to store. Non-string values will be converted to a string via JSON.stringify
*/
// eslint-disable-next-line @typescript-eslint/no-explicit-any
function saveState(name, value) {
command_1.issueCommand('save-state', { name }, value);
}
@@ -377,86 +964,10 @@ module.exports = require("path");
/***/ }),
/***/ 676:
/***/ (function(__unusedmodule, __unusedexports, __webpack_require__) {
const { getInput, error, warning, info } = __webpack_require__(470);
const { spawn } = __webpack_require__(129);
const { join } = __webpack_require__(622);
const ms = __webpack_require__(156);
var kill = __webpack_require__(791);
function getInputNumber(id, required) {
const input = getInput(id, { required });
const num = Number.parseInt(input);
if (!Number.isInteger(num)) {
throw `Input ${id} only accepts numbers. Received ${input}`;
}
return num;
}
// inputs
const TIMEOUT_MINUTES = getInputNumber('timeout_minutes', true);
const MAX_ATTEMPTS = getInputNumber('max_attempts', true);
const COMMAND = getInput('command', { required: true });
const RETRY_WAIT_SECONDS = getInputNumber('retry_wait_seconds', false);
const POLLING_INTERVAL_SECONDS = getInputNumber('polling_interval_seconds', false);
async function wait(ms) {
return new Promise(r => setTimeout(r, ms));
}
async function runCmd() {
const end_time = Date.now() + ms.minutes(TIMEOUT_MINUTES);
var done, exit;
var child = spawn('node', [__webpack_require__.ab + "exec.js", COMMAND], { stdio: 'inherit' });
child.on('exit', code => {
if (code > 0) {
exit = code;
}
done = true;
});
do {
await wait(ms.seconds(POLLING_INTERVAL_SECONDS));
} while (Date.now() < end_time && !done && !exit);
if (!done) {
kill(child.pid);
await wait(ms.seconds(RETRY_WAIT_SECONDS));
throw new Error(`Timeout of ${TIMEOUT_MINUTES}m hit`);
} else if (exit > 0) {
throw new Error(`Child_process exited with error`);
} else {
return;
}
}
async function runAction() {
for (let attempt = 1; attempt <= MAX_ATTEMPTS; attempt++) {
try {
await runCmd();
info(`Command completed after ${attempt} attempt(s).`);
break;
} catch (error) {
if (attempt === MAX_ATTEMPTS) {
throw new Error(`Final attempt failed. ${error.message}`);
} else {
warning(`Attempt ${attempt} failed. Reason:`, error.message);
}
}
}
}
runAction().catch(err => {
error(err.message);
process.exit(1);
});
/***/ 747:
/***/ (function(module) {
module.exports = require("fs");
/***/ }),

3120
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -3,7 +3,8 @@
"version": "0.0.0-managed-by-semantic-release",
"description": "Retries a GitHub Action step on failure or timeout.",
"scripts": {
"prepare": "ncc build src/index.js"
"local": "npm run prepare && node -r dotenv/config ./dist/index.js",
"prepare": "ncc build src/index.ts"
},
"repository": {
"type": "git",
@@ -17,18 +18,23 @@
},
"homepage": "https://github.com/nick-invision/retry#readme",
"dependencies": {
"@actions/core": "^1.2.4",
"@actions/core": "^1.5.0",
"milliseconds": "^1.0.3",
"tree-kill": "^1.2.2"
},
"devDependencies": {
"@commitlint/cli": "^8.3.5",
"@commitlint/config-conventional": "^8.3.4",
"@semantic-release/changelog": "^3.0.6",
"@semantic-release/git": "^7.0.18",
"@commitlint/cli": "11.0.0",
"@commitlint/config-conventional": "11.0.0",
"@semantic-release/changelog": "5.0.1",
"@semantic-release/git": "9.0.0",
"@types/milliseconds": "0.0.30",
"@types/node": "14.14.7",
"@zeit/ncc": "^0.20.5",
"husky": "^3.1.0",
"semantic-release": "^17.0.3"
"dotenv": "8.2.0",
"husky": "4.3.0",
"semantic-release": "17.2.3",
"ts-node": "9.0.0",
"typescript": "4.0.5"
},
"husky": {
"hooks": {

7
sample.env Normal file
View File

@@ -0,0 +1,7 @@
INPUT_TIMEOUT_MINUTES=1
INPUT_MAX_ATTEMPTS=3
INPUT_COMMAND="node -e 'process.exit(99)'"
INPUT_RETRY_WAIT_SECONDS=10
SHELL=pwsh
INPUT_POLLING_INTERVAL_SECONDS=1
INPUT_RETRY_ON=any

View File

@@ -1,8 +0,0 @@
const { execSync } = require('child_process');
const COMMAND = process.argv.splice(2)[0];
function run() {
execSync(COMMAND, { stdio: 'inherit' });
}
run();

View File

@@ -1,76 +0,0 @@
const { getInput, error, warning, info } = require('@actions/core');
const { spawn } = require('child_process');
const { join } = require('path');
const ms = require('milliseconds');
var kill = require('tree-kill');
function getInputNumber(id, required) {
const input = getInput(id, { required });
const num = Number.parseInt(input);
if (!Number.isInteger(num)) {
throw `Input ${id} only accepts numbers. Received ${input}`;
}
return num;
}
// inputs
const TIMEOUT_MINUTES = getInputNumber('timeout_minutes', true);
const MAX_ATTEMPTS = getInputNumber('max_attempts', true);
const COMMAND = getInput('command', { required: true });
const RETRY_WAIT_SECONDS = getInputNumber('retry_wait_seconds', false);
const POLLING_INTERVAL_SECONDS = getInputNumber('polling_interval_seconds', false);
async function wait(ms) {
return new Promise(r => setTimeout(r, ms));
}
async function runCmd() {
const end_time = Date.now() + ms.minutes(TIMEOUT_MINUTES);
var done, exit;
var child = spawn('node', [join(__dirname, 'exec.js'), COMMAND], { stdio: 'inherit' });
child.on('exit', code => {
if (code > 0) {
exit = code;
}
done = true;
});
do {
await wait(ms.seconds(POLLING_INTERVAL_SECONDS));
} while (Date.now() < end_time && !done && !exit);
if (!done) {
kill(child.pid);
await wait(ms.seconds(RETRY_WAIT_SECONDS));
throw new Error(`Timeout of ${TIMEOUT_MINUTES}m hit`);
} else if (exit > 0) {
throw new Error(`Child_process exited with error`);
} else {
return;
}
}
async function runAction() {
for (let attempt = 1; attempt <= MAX_ATTEMPTS; attempt++) {
try {
await runCmd();
info(`Command completed after ${attempt} attempt(s).`);
break;
} catch (error) {
if (attempt === MAX_ATTEMPTS) {
throw new Error(`Final attempt failed. ${error.message}`);
} else {
warning(`Attempt ${attempt} failed. Reason:`, error.message);
}
}
}
}
runAction().catch(err => {
error(err.message);
process.exit(1);
});

227
src/index.ts Normal file
View File

@@ -0,0 +1,227 @@
import { getInput, error, warning, info, debug, setOutput } from '@actions/core';
import { exec, execSync } from 'child_process';
import ms from 'milliseconds';
import kill from 'tree-kill';
import { wait } from './util';
// inputs
const TIMEOUT_MINUTES = getInputNumber('timeout_minutes', false);
const TIMEOUT_SECONDS = getInputNumber('timeout_seconds', false);
const MAX_ATTEMPTS = getInputNumber('max_attempts', true) || 3;
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 OS = process.platform;
const OUTPUT_TOTAL_ATTEMPTS_KEY = 'total_attempts';
const OUTPUT_EXIT_CODE_KEY = 'exit_code';
const OUTPUT_EXIT_ERROR_KEY = 'exit_error';
var exit: number;
var done: boolean;
function getInputNumber(id: string, required: boolean): number | undefined {
const input = getInput(id, { required });
const num = Number.parseInt(input);
// empty is ok
if (!input && !required) {
return;
}
if (!Number.isInteger(num)) {
throw `Input ${id} only accepts numbers. Received ${input}`;
}
return num;
}
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'
}
async function retryWait() {
const waitStart = Date.now();
await wait(ms.seconds(RETRY_WAIT_SECONDS));
debug(`Waited ${Date.now() - waitStart}ms`);
debug(`Configured wait: ${ms.seconds(RETRY_WAIT_SECONDS)}ms`);
}
async function validateInputs() {
if ((!TIMEOUT_MINUTES && !TIMEOUT_SECONDS) || (TIMEOUT_MINUTES && TIMEOUT_SECONDS)) {
throw new Error('Must specify either timeout_minutes or timeout_seconds inputs');
}
}
function getTimeout(): number {
if (TIMEOUT_MINUTES) {
return ms.minutes(TIMEOUT_MINUTES);
} else if (TIMEOUT_SECONDS) {
return ms.seconds(TIMEOUT_SECONDS);
}
throw new Error('Must specify either timeout_minutes or timeout_seconds inputs');
}
function getExecutable(): string {
if (!SHELL) {
return OS === 'win32' ? 'powershell' : 'bash';
}
let executable: string;
switch (SHELL) {
case "bash":
case "python":
case "pwsh": {
executable = SHELL;
break;
}
case "sh": {
if (OS === 'win32') {
throw new Error(`Shell ${SHELL} not allowed on OS ${OS}`);
}
executable = SHELL;
break;
}
case "cmd":
case "powershell": {
if (OS !== 'win32') {
throw new Error(`Shell ${SHELL} not allowed on OS ${OS}`);
}
executable = SHELL + ".exe";
break;
}
default: {
throw new Error(`Shell ${SHELL} not supported. See https://docs.github.com/en/free-pro-team@latest/actions/reference/workflow-syntax-for-github-actions#using-a-specific-shell for supported shells`);
}
}
return executable
}
async function runRetryCmd(): Promise<void> {
// if no retry script, just continue
if (!ON_RETRY_COMMAND) {
return;
}
try {
await execSync(ON_RETRY_COMMAND, { stdio: 'inherit' });
} catch (error) {
info(`WARNING: Retry command threw the error ${error.message}`)
}
}
async function runCmd(attempt: number) {
const end_time = Date.now() + getTimeout();
const executable = getExecutable();
exit = 0;
done = false;
debug(`Running command ${COMMAND} on ${OS} using shell ${executable}`)
var child = attempt > 1 && NEW_COMMAND_ON_RETRY
? exec(NEW_COMMAND_ON_RETRY, { 'shell': executable })
: exec(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}`);
if (code && code > 0) {
exit = code;
}
// timeouts are killed manually
if (signal === 'SIGTERM') {
return;
}
done = true;
});
do {
await wait(ms.seconds(POLLING_INTERVAL_SECONDS));
} while (Date.now() < end_time && !done);
if (!done) {
kill(child.pid);
await retryWait();
throw new Error(`Timeout of ${getTimeout()}ms hit`);
} else if (exit > 0) {
await retryWait();
throw new Error(`Child_process exited with error code ${exit}`);
} else {
return;
}
}
async function runAction() {
await validateInputs();
for (let attempt = 1; attempt <= MAX_ATTEMPTS; attempt++) {
try {
// just keep overwriting attempts output
setOutput(OUTPUT_TOTAL_ATTEMPTS_KEY, attempt);
await runCmd(attempt);
info(`Command completed after ${attempt} attempt(s).`);
break;
} catch (error) {
if (attempt === MAX_ATTEMPTS) {
throw new Error(`Final attempt failed. ${error.message}`);
} else if (!done && RETRY_ON === 'error') {
// error: timeout
throw error;
} else if (exit > 0 && RETRY_ON === 'timeout') {
// error: error
throw error;
} else {
await runRetryCmd();
if (WARNING_ON_RETRY) {
warning(`Attempt ${attempt} failed. Reason: ${error.message}`);
} else {
info(`Attempt ${attempt} failed. Reason: ${error.message}`);
}
}
}
}
}
runAction()
.then(() => {
setOutput(OUTPUT_EXIT_CODE_KEY, 0);
process.exit(0); // success
})
.catch((err) => {
// exact error code if available, otherwise just 1
const exitCode = exit > 0 ? exit : 1;
if (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(CONTINUE_ON_ERROR ? 0 : exitCode);
});

3
src/util.ts Normal file
View File

@@ -0,0 +1,3 @@
export async function wait(ms: number) {
return new Promise((r) => setTimeout(r, ms));
}

16
tsconfig.json Normal file
View File

@@ -0,0 +1,16 @@
{
"compilerOptions": {
/* Basic Options */
"target": "es5" /* Specify ECMAScript target version: 'ES3' (default), 'ES5', 'ES2015', 'ES2016', 'ES2017', 'ES2018', 'ES2019' or 'ESNEXT'. */,
"module": "commonjs" /* Specify module code generation: 'none', 'commonjs', 'amd', 'system', 'umd', 'es2015', or 'ESNext'. */,
"noEmit": true /* Do not emit outputs. */,
/* Strict Type-Checking Options */
"strict": true /* Enable all strict type-checking options. */,
"esModuleInterop": true /* Enables emit interoperability between CommonJS and ES Modules via creation of namespace objects for all imports. Implies 'allowSyntheticDefaultImports'. */,
/* Advanced Options */
"forceConsistentCasingInFileNames": true /* Disallow inconsistently-cased references to the same file. */
},
"exclude": ["**/__tests__", "**/__mocks__"]
}