Compare commits

...

9 Commits
v1 ... v2.0.0

Author SHA1 Message Date
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
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
10 changed files with 540 additions and 141 deletions

View File

@@ -18,13 +18,21 @@ jobs:
node-version: 12
- 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: sad-path (retry_wait_seconds)
id: sad_path_wait_sec
uses: ./
continue-on-error: true
with:
@@ -32,20 +40,156 @@ jobs:
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: 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 }}
# 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 }}
# runs on push to master only
cd:

View File

@@ -24,12 +24,97 @@ Retries an Action step on failure or timeout. This is currently intended to repl
**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].
## 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
### Timeout in minutes
```yaml
uses: nick-invision/retry@v1
uses: nick-invision/retry@v2
with:
timeout_minutes: 10
max_attempts: 3
command: npm install
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 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 }}
```
## 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
@@ -19,6 +22,15 @@ inputs:
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]
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 +1,12 @@
const { execSync } = require('child_process');
const { exec } = require('child_process');
const COMMAND = process.argv.splice(2)[0];
function run() {
execSync(COMMAND, { stdio: 'inherit' });
exec(COMMAND, { stdio: 'inherit' }, (err) => {
if (err) {
process.exit(err.code);
}
});
}
run();

147
dist/index.js vendored
View File

@@ -414,16 +414,37 @@ module.exports = require("path");
/***/ 676:
/***/ (function(__unusedmodule, __unusedexports, __webpack_require__) {
const { getInput, error, warning, info, debug } = __webpack_require__(470);
const { getInput, error, warning, info, debug, setOutput } = __webpack_require__(470);
const { spawn } = __webpack_require__(129);
const { join } = __webpack_require__(622);
const ms = __webpack_require__(156);
var kill = __webpack_require__(791);
// inputs
const TIMEOUT_MINUTES = getInputNumber('timeout_minutes', false);
const TIMEOUT_SECONDS = getInputNumber('timeout_seconds', false);
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);
const RETRY_ON = getInput('retry_on') || 'any';
const OUTPUT_TOTAL_ATTEMPTS_KEY = 'total_attempts';
const OUTPUT_EXIT_CODE_KEY = 'exit_code';
const OUTPUT_EXIT_ERROR_KEY = 'exit_error';
var exit;
var done;
function getInputNumber(id, required) {
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}`;
}
@@ -431,46 +452,10 @@ function getInputNumber(id, required) {
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 retryWait();
throw new Error(`Timeout of ${TIMEOUT_MINUTES}m hit`);
} else if (exit > 0) {
await retryWait();
throw new Error(`Child_process exited with error`);
} else {
return;
}
}
async function retryWait() {
const waitStart = Date.now();
await wait(ms.seconds(RETRY_WAIT_SECONDS));
@@ -478,26 +463,104 @@ async function retryWait() {
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');
}
if (TIMEOUT_SECONDS && TIMEOUT_SECONDS < RETRY_WAIT_SECONDS) {
throw new Error(
`timeout_seconds ${TIMEOUT_SECONDS}s less than retry_wait_seconds ${RETRY_WAIT_SECONDS}s`
);
}
}
function getTimeout() {
if (TIMEOUT_MINUTES) {
return ms.minutes(TIMEOUT_MINUTES);
}
return ms.seconds(TIMEOUT_SECONDS);
}
async function runCmd() {
const end_time = Date.now() + getTimeout();
exit = 0;
done = false;
var child = spawn('node', [__webpack_require__.ab + "exec.js", COMMAND], { stdio: 'inherit' });
child.on('exit', (code, signal) => {
debug(`Code: ${code}`);
debug(`Signal: ${signal}`);
if (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();
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 {
warning(`Attempt ${attempt} failed. Reason:`, error.message);
warning(`Attempt ${attempt} failed. Reason: ${error.message}`);
}
}
}
}
runAction().catch((err) => {
error(err.message);
process.exit(1);
});
runAction()
.then(() => {
setOutput(OUTPUT_EXIT_CODE_KEY, 0);
process.exit(0); // success
})
.catch((err) => {
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, exit > 0 ? exit : 1);
// exit with exact error code if available, otherwise just exit with 1
process.exit(exit > 0 ? exit : 1);
});
/***/ }),

110
package-lock.json generated
View File

@@ -1501,6 +1501,12 @@
"is-obj": "^1.0.0"
}
},
"dotenv": {
"version": "8.2.0",
"resolved": "https://registry.npmjs.org/dotenv/-/dotenv-8.2.0.tgz",
"integrity": "sha512-8sJ78ElpbDJBHNeBzUbUVLsqKdccaa/BXF1uPTw3GrvQTBgrQrtObr2mUrE38vzYd8cEv+m/JBfDLioYcfXoaw==",
"dev": true
},
"duplexer2": {
"version": "0.1.4",
"resolved": "https://registry.npmjs.org/duplexer2/-/duplexer2-0.1.4.tgz",
@@ -2573,9 +2579,9 @@
"dev": true
},
"npm": {
"version": "6.13.7",
"resolved": "https://registry.npmjs.org/npm/-/npm-6.13.7.tgz",
"integrity": "sha512-X967EKTT407CvgrWFjXusnPh0VLERcmR9hZFSVgkEquOomZkvpwLJ5zrQ3qrG9SpPLKJE4bPLUu76exKQ4a3Cg==",
"version": "6.14.6",
"resolved": "https://registry.npmjs.org/npm/-/npm-6.14.6.tgz",
"integrity": "sha512-axnz6iHFK6WPE0js/+mRp+4IOwpHn5tJEw5KB6FiCU764zmffrhsYHbSHi2kKqNkRBt53XasXjngZfBD3FQzrQ==",
"dev": true,
"requires": {
"JSONStream": "^1.3.5",
@@ -2589,7 +2595,7 @@
"byte-size": "^5.0.1",
"cacache": "^12.0.3",
"call-limit": "^1.1.1",
"chownr": "^1.1.3",
"chownr": "^1.1.4",
"ci-info": "^2.0.0",
"cli-columns": "^3.1.2",
"cli-table3": "^0.5.1",
@@ -2606,10 +2612,10 @@
"fs-vacuum": "~1.2.10",
"fs-write-stream-atomic": "~1.0.10",
"gentle-fs": "^2.3.0",
"glob": "^7.1.4",
"graceful-fs": "^4.2.3",
"glob": "^7.1.6",
"graceful-fs": "^4.2.4",
"has-unicode": "~2.0.1",
"hosted-git-info": "^2.8.5",
"hosted-git-info": "^2.8.8",
"iferr": "^1.0.2",
"imurmurhash": "*",
"infer-owner": "^1.0.4",
@@ -2644,20 +2650,20 @@
"lru-cache": "^5.1.1",
"meant": "~1.0.1",
"mississippi": "^3.0.0",
"mkdirp": "~0.5.1",
"mkdirp": "^0.5.5",
"move-concurrently": "^1.0.1",
"node-gyp": "^5.0.7",
"nopt": "~4.0.1",
"node-gyp": "^5.1.0",
"nopt": "^4.0.3",
"normalize-package-data": "^2.5.0",
"npm-audit-report": "^1.3.2",
"npm-cache-filename": "~1.0.2",
"npm-install-checks": "^3.0.2",
"npm-lifecycle": "^3.1.4",
"npm-package-arg": "^6.1.1",
"npm-packlist": "^1.4.7",
"npm-packlist": "^1.4.8",
"npm-pick-manifest": "^3.0.2",
"npm-profile": "^4.0.2",
"npm-registry-fetch": "^4.0.2",
"npm-profile": "^4.0.4",
"npm-registry-fetch": "^4.0.5",
"npm-user-validate": "~1.0.0",
"npmlog": "~4.1.2",
"once": "~1.4.0",
@@ -2674,11 +2680,11 @@
"read-installed": "~4.0.3",
"read-package-json": "^2.1.1",
"read-package-tree": "^5.3.1",
"readable-stream": "^3.4.0",
"readable-stream": "^3.6.0",
"readdir-scoped-modules": "^1.1.0",
"request": "^2.88.0",
"retry": "^0.12.0",
"rimraf": "^2.6.3",
"rimraf": "^2.7.1",
"safe-buffer": "^5.1.2",
"semver": "^5.7.1",
"sha": "^3.0.0",
@@ -2979,7 +2985,7 @@
}
},
"chownr": {
"version": "1.1.3",
"version": "1.1.4",
"bundled": true,
"dev": true
},
@@ -3285,7 +3291,7 @@
"dev": true
},
"deep-extend": {
"version": "0.5.1",
"version": "0.6.0",
"bundled": true,
"dev": true
},
@@ -3784,7 +3790,7 @@
}
},
"glob": {
"version": "7.1.4",
"version": "7.1.6",
"bundled": true,
"dev": true,
"requires": {
@@ -3830,7 +3836,7 @@
}
},
"graceful-fs": {
"version": "4.2.3",
"version": "4.2.4",
"bundled": true,
"dev": true
},
@@ -3872,7 +3878,7 @@
"dev": true
},
"hosted-git-info": {
"version": "2.8.5",
"version": "2.8.8",
"bundled": true,
"dev": true
},
@@ -4008,11 +4014,11 @@
"dev": true
},
"is-ci": {
"version": "1.1.0",
"version": "1.2.1",
"bundled": true,
"dev": true,
"requires": {
"ci-info": "^1.0.0"
"ci-info": "^1.5.0"
},
"dependencies": {
"ci-info": {
@@ -4084,7 +4090,7 @@
}
},
"is-retry-allowed": {
"version": "1.1.0",
"version": "1.2.0",
"bundled": true,
"dev": true
},
@@ -4556,11 +4562,6 @@
"brace-expansion": "^1.1.7"
}
},
"minimist": {
"version": "0.0.8",
"bundled": true,
"dev": true
},
"minizlib": {
"version": "1.3.3",
"bundled": true,
@@ -4598,11 +4599,18 @@
}
},
"mkdirp": {
"version": "0.5.1",
"version": "0.5.5",
"bundled": true,
"dev": true,
"requires": {
"minimist": "0.0.8"
"minimist": "^1.2.5"
},
"dependencies": {
"minimist": {
"version": "1.2.5",
"bundled": true,
"dev": true
}
}
},
"move-concurrently": {
@@ -4651,7 +4659,7 @@
}
},
"node-gyp": {
"version": "5.0.7",
"version": "5.1.0",
"bundled": true,
"dev": true,
"requires": {
@@ -4669,7 +4677,7 @@
}
},
"nopt": {
"version": "4.0.1",
"version": "4.0.3",
"bundled": true,
"dev": true,
"requires": {
@@ -4765,12 +4773,13 @@
}
},
"npm-packlist": {
"version": "1.4.7",
"version": "1.4.8",
"bundled": true,
"dev": true,
"requires": {
"ignore-walk": "^3.0.1",
"npm-bundled": "^1.0.1"
"npm-bundled": "^1.0.1",
"npm-normalize-package-bin": "^1.0.1"
}
},
"npm-pick-manifest": {
@@ -4784,7 +4793,7 @@
}
},
"npm-profile": {
"version": "4.0.2",
"version": "4.0.4",
"bundled": true,
"dev": true,
"requires": {
@@ -4794,7 +4803,7 @@
}
},
"npm-registry-fetch": {
"version": "4.0.2",
"version": "4.0.5",
"bundled": true,
"dev": true,
"requires": {
@@ -4808,7 +4817,7 @@
},
"dependencies": {
"safe-buffer": {
"version": "5.2.0",
"version": "5.2.1",
"bundled": true,
"dev": true
}
@@ -5229,18 +5238,18 @@
"dev": true
},
"rc": {
"version": "1.2.7",
"version": "1.2.8",
"bundled": true,
"dev": true,
"requires": {
"deep-extend": "^0.5.1",
"deep-extend": "^0.6.0",
"ini": "~1.3.0",
"minimist": "^1.2.0",
"strip-json-comments": "~2.0.1"
},
"dependencies": {
"minimist": {
"version": "1.2.0",
"version": "1.2.5",
"bundled": true,
"dev": true
}
@@ -5299,7 +5308,7 @@
}
},
"readable-stream": {
"version": "3.4.0",
"version": "3.6.0",
"bundled": true,
"dev": true,
"requires": {
@@ -5320,7 +5329,7 @@
}
},
"registry-auth-token": {
"version": "3.3.2",
"version": "3.4.0",
"bundled": true,
"dev": true,
"requires": {
@@ -5384,7 +5393,7 @@
"dev": true
},
"rimraf": {
"version": "2.6.3",
"version": "2.7.1",
"bundled": true,
"dev": true,
"requires": {
@@ -5568,7 +5577,7 @@
}
},
"spdx-license-ids": {
"version": "3.0.3",
"version": "3.0.5",
"bundled": true,
"dev": true
},
@@ -5683,11 +5692,18 @@
}
},
"string_decoder": {
"version": "1.2.0",
"version": "1.3.0",
"bundled": true,
"dev": true,
"requires": {
"safe-buffer": "~5.1.0"
"safe-buffer": "~5.2.0"
},
"dependencies": {
"safe-buffer": {
"version": "5.2.0",
"bundled": true,
"dev": true
}
}
},
"stringify-package": {
@@ -5996,7 +6012,7 @@
}
},
"widest-line": {
"version": "2.0.0",
"version": "2.0.1",
"bundled": true,
"dev": true,
"requires": {

View File

@@ -3,6 +3,7 @@
"version": "0.0.0-managed-by-semantic-release",
"description": "Retries a GitHub Action step on failure or timeout.",
"scripts": {
"local": "node -r dotenv/config ./src/index.js",
"prepare": "ncc build src/index.js"
},
"repository": {
@@ -27,6 +28,7 @@
"@semantic-release/changelog": "^3.0.6",
"@semantic-release/git": "^7.0.18",
"@zeit/ncc": "^0.20.5",
"dotenv": "8.2.0",
"husky": "^3.1.0",
"semantic-release": "^17.0.3"
},

6
sample.env Normal file
View File

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

View File

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

View File

@@ -1,13 +1,34 @@
const { getInput, error, warning, info, debug } = require('@actions/core');
const { getInput, error, warning, info, debug, setOutput } = require('@actions/core');
const { spawn } = require('child_process');
const { join } = require('path');
const ms = require('milliseconds');
var kill = require('tree-kill');
// inputs
const TIMEOUT_MINUTES = getInputNumber('timeout_minutes', false);
const TIMEOUT_SECONDS = getInputNumber('timeout_seconds', false);
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);
const RETRY_ON = getInput('retry_on') || 'any';
const OUTPUT_TOTAL_ATTEMPTS_KEY = 'total_attempts';
const OUTPUT_EXIT_CODE_KEY = 'exit_code';
const OUTPUT_EXIT_ERROR_KEY = 'exit_error';
var exit;
var done;
function getInputNumber(id, required) {
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}`;
}
@@ -15,46 +36,10 @@ function getInputNumber(id, required) {
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 retryWait();
throw new Error(`Timeout of ${TIMEOUT_MINUTES}m hit`);
} else if (exit > 0) {
await retryWait();
throw new Error(`Child_process exited with error`);
} else {
return;
}
}
async function retryWait() {
const waitStart = Date.now();
await wait(ms.seconds(RETRY_WAIT_SECONDS));
@@ -62,23 +47,101 @@ async function retryWait() {
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');
}
if (TIMEOUT_SECONDS && TIMEOUT_SECONDS < RETRY_WAIT_SECONDS) {
throw new Error(
`timeout_seconds ${TIMEOUT_SECONDS}s less than retry_wait_seconds ${RETRY_WAIT_SECONDS}s`
);
}
}
function getTimeout() {
if (TIMEOUT_MINUTES) {
return ms.minutes(TIMEOUT_MINUTES);
}
return ms.seconds(TIMEOUT_SECONDS);
}
async function runCmd() {
const end_time = Date.now() + getTimeout();
exit = 0;
done = false;
var child = spawn('node', [join(__dirname, 'exec.js'), COMMAND], { stdio: 'inherit' });
child.on('exit', (code, signal) => {
debug(`Code: ${code}`);
debug(`Signal: ${signal}`);
if (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();
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 {
warning(`Attempt ${attempt} failed. Reason:`, error.message);
warning(`Attempt ${attempt} failed. Reason: ${error.message}`);
}
}
}
}
runAction().catch((err) => {
error(err.message);
process.exit(1);
});
runAction()
.then(() => {
setOutput(OUTPUT_EXIT_CODE_KEY, 0);
process.exit(0); // success
})
.catch((err) => {
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, exit > 0 ? exit : 1);
// exit with exact error code if available, otherwise just exit with 1
process.exit(exit > 0 ? exit : 1);
});