Compare commits

...

25 Commits

Author SHA1 Message Date
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
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
Nick Fields
8eea3dc8c5 Merge pull request #3 from nick-invision/snyk-upgrade-ec7232b40f6331df5e0dca1e40351e54
[Snyk] Upgrade @actions/core from 1.2.3 to 1.2.4
2020-06-17 13:05:16 -04:00
snyk-bot
9b191450bf fix: upgrade @actions/core from 1.2.3 to 1.2.4
Snyk has created this PR to upgrade @actions/core from 1.2.3 to 1.2.4.

See this package in NPM:
https://www.npmjs.com/package/@actions/core

See this project in Snyk:
https://app.snyk.io/org/nick-invision/project/b960b937-66a3-4aae-9cb2-321f49c8750b?utm_source=github&utm_medium=upgrade-pr
2020-05-21 20:50:35 -04:00
snyk-bot
0d89fa3a93 fix: upgrade @actions/core from 1.2.3 to 1.2.4
Snyk has created this PR to upgrade @actions/core from 1.2.3 to 1.2.4.

See this package in NPM:
https://www.npmjs.com/package/@actions/core

See this project in Snyk:
https://app.snyk.io/org/nick-invision/project/b960b937-66a3-4aae-9cb2-321f49c8750b?utm_source=github&utm_medium=upgrade-pr
2020-05-21 20:50:34 -04:00
12 changed files with 613 additions and 138 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).

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

@@ -0,0 +1,3 @@
console.log('console.log test');
process.stdout.write('stdout test\n');
process.stderr.write('stderr test\n');

View File

@@ -18,33 +18,184 @@ 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: 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:
@@ -67,7 +218,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

@@ -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'

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();

179
dist/index.js vendored
View File

@@ -143,14 +143,28 @@ class Command {
return cmdStr;
}
}
/**
* 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;
function escapeData(s) {
return (s || '')
return toCommandValue(s)
.replace(/%/g, '%25')
.replace(/\r/g, '%0D')
.replace(/\n/g, '%0A');
}
function escapeProperty(s) {
return (s || '')
return toCommandValue(s)
.replace(/%/g, '%25')
.replace(/\r/g, '%0D')
.replace(/\n/g, '%0A')
@@ -206,11 +220,13 @@ 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 = command_1.toCommandValue(val);
process.env[name] = convertedVal;
command_1.issueCommand('set-env', { name }, convertedVal);
}
exports.exportVariable = exportVariable;
/**
@@ -249,12 +265,22 @@ exports.getInput = getInput;
* 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) {
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 +297,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,18 +314,18 @@ 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()
*/
function error(message) {
command_1.issue('error', message);
command_1.issue('error', message instanceof Error ? message.toString() : message);
}
exports.error = error;
/**
* Adds an warning issue
* @param message warning issue message
* @param message warning issue message. Errors will be converted to string via toString()
*/
function warning(message) {
command_1.issue('warning', message);
command_1.issue('warning', message instanceof Error ? message.toString() : message);
}
exports.warning = warning;
/**
@@ -350,8 +383,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);
}
@@ -380,16 +414,37 @@ module.exports = require("path");
/***/ 676:
/***/ (function(__unusedmodule, __unusedexports, __webpack_require__) {
const { getInput, error, warning, info } = __webpack_require__(470);
const { spawn } = __webpack_require__(129);
const { getInput, error, warning, info, debug, setOutput } = __webpack_require__(470);
const child_process = __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}`;
}
@@ -397,64 +452,124 @@ 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));
return new Promise((r) => setTimeout(r, ms));
}
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');
}
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() + ms.minutes(TIMEOUT_MINUTES);
var done, exit;
const end_time = Date.now() + getTimeout();
var child = spawn('node', [__webpack_require__.ab + "exec.js", COMMAND], { stdio: 'inherit' });
exit = 0;
done = false;
child.on('exit', code => {
const file = COMMAND.split(' ')[0];
const args = COMMAND.split(' ').slice(1);
var child = child_process.exec(COMMAND, { stdio: 'inherit' });
child.stdout.on('data', (data) => {
console.log(data);
});
child.stderr.on('data', (data) => {
console.log(data);
});
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 && !exit);
} while (Date.now() < end_time && !done);
if (!done) {
kill(child.pid);
await wait(ms.seconds(RETRY_WAIT_SECONDS));
throw new Error(`Timeout of ${TIMEOUT_MINUTES}m hit`);
await retryWait();
throw new Error(`Timeout of ${getTimeout()}ms hit`);
} else if (exit > 0) {
throw new Error(`Child_process exited with error`);
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 => {
runAction()
.then(() => {
setOutput(OUTPUT_EXIT_CODE_KEY, 0);
process.exit(0); // success
})
.catch((err) => {
error(err.message);
process.exit(1);
// 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);
});

116
package-lock.json generated
View File

@@ -5,9 +5,9 @@
"requires": true,
"dependencies": {
"@actions/core": {
"version": "1.2.3",
"resolved": "https://registry.npmjs.org/@actions/core/-/core-1.2.3.tgz",
"integrity": "sha512-Wp4xnyokakM45Uuj4WLUxdsa8fJjKVl1fDTsPbTEcTcuu0Nb26IPQbOtjmnfaCPGcaoPOOqId8H9NapZ8gii4w=="
"version": "1.2.6",
"resolved": "https://registry.npmjs.org/@actions/core/-/core-1.2.6.tgz",
"integrity": "sha512-ZQYitnqiyBc3D+k7LsgSBmMDVkOVidaagDG7j3fOym77jNunWRuYx7VSHa9GNfFZh+zh61xsCjRj4JxMZlDqTA=="
},
"@babel/code-frame": {
"version": "7.8.3",
@@ -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": {
@@ -17,7 +18,7 @@
},
"homepage": "https://github.com/nick-invision/retry#readme",
"dependencies": {
"@actions/core": "^1.2.3",
"@actions/core": "^1.2.6",
"milliseconds": "^1.0.3",
"tree-kill": "^1.2.2"
},
@@ -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 +0,0 @@
const { execSync } = require('child_process');
const COMMAND = process.argv.splice(2)[0];
function run() {
execSync(COMMAND, { stdio: 'inherit' });
}
run();

View File

@@ -1,13 +1,34 @@
const { getInput, error, warning, info } = require('@actions/core');
const { spawn } = require('child_process');
const { getInput, error, warning, info, debug, setOutput } = require('@actions/core');
const child_process = 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,62 +36,122 @@ 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));
return new Promise((r) => setTimeout(r, ms));
}
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');
}
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() + ms.minutes(TIMEOUT_MINUTES);
var done, exit;
const end_time = Date.now() + getTimeout();
var child = spawn('node', [join(__dirname, 'exec.js'), COMMAND], { stdio: 'inherit' });
exit = 0;
done = false;
child.on('exit', code => {
const file = COMMAND.split(' ')[0];
const args = COMMAND.split(' ').slice(1);
var child = child_process.exec(COMMAND, { stdio: 'inherit' });
child.stdout.on('data', (data) => {
console.log(data);
});
child.stderr.on('data', (data) => {
console.log(data);
});
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 && !exit);
} while (Date.now() < end_time && !done);
if (!done) {
kill(child.pid);
await wait(ms.seconds(RETRY_WAIT_SECONDS));
throw new Error(`Timeout of ${TIMEOUT_MINUTES}m hit`);
await retryWait();
throw new Error(`Timeout of ${getTimeout()}ms hit`);
} else if (exit > 0) {
throw new Error(`Child_process exited with error`);
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 => {
runAction()
.then(() => {
setOutput(OUTPUT_EXIT_CODE_KEY, 0);
process.exit(0); // success
})
.catch((err) => {
error(err.message);
process.exit(1);
// 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);
});