From ec785f59e19357c9cd04b5f81ed76080f2e213a9 Mon Sep 17 00:00:00 2001 From: Nick Fields Date: Tue, 29 Sep 2020 10:48:02 -0400 Subject: [PATCH] minor: added tests and helper outputs for PR #15 --- .github/workflows/ci_cd.yml | 85 +++++++++++++++++++++++++++++++++++++ action.yml | 11 ++++- dist/index.js | 59 ++++++++++++++++++------- src/index.js | 57 ++++++++++++++----------- 4 files changed, 171 insertions(+), 41 deletions(-) diff --git a/.github/workflows/ci_cd.yml b/.github/workflows/ci_cd.yml index f820246..8eab28c 100644 --- a/.github/workflows/ci_cd.yml +++ b/.github/workflows/ci_cd.yml @@ -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,97 @@ 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: true + actual: ${{ steps.sad_path_wait_sec.outputs.total_attempts == '3' && steps.sad_path_wait_sec.outcome == 'failure' }} + - uses: nick-invision/assert-action@v1 + with: + expected: 'Command failed: npm install' + 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: true + actual: ${{ steps.sad_path_error.outputs.total_attempts == '2' && steps.sad_path_error.outcome == 'failure' }} + - name: sad-path (timeout) + id: sad_path_timeout 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: true + actual: ${{ steps.sad_path_timeout.outputs.total_attempts == '2' && steps.sad_path_timeout.outcome == 'failure' }} + + - name: retry_on (timeout) + id: retry_on_timeout + uses: ./ + continue-on-error: true + with: + timeout_minutes: 1 + 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: true + actual: ${{ steps.retry_on_timeout.outputs.total_attempts == '2' && steps.retry_on_timeout.outcome == 'failure' }} + + - name: retry_on (timeout) fails early if nonzero 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: true + actual: ${{ steps.retry_on_timeout_fail.outputs.total_attempts == '1' && steps.retry_on_timeout_fail.outcome == 'failure' && steps.retry_on_timeout_fail.outputs.exit_code == '2' }} + + - name: retry_on (nonzero) + id: retry_on_nonzero + uses: ./ + continue-on-error: true + with: + timeout_minutes: 1 + max_attempts: 2 + retry_on: timeout + command: node -e "process.exit(2)" + - uses: nick-invision/assert-action@v1 + with: + expected: true + actual: ${{ steps.retry_on_nonzero.outputs.total_attempts == '2' && steps.retry_on_nonzero.outcome == 'failure' && steps.retry_on_nonzero.outputs.exit_code == '2' }} + + - name: retry_on (nonzero) fails early if timeout encountered + id: retry_on_nonzero_fail + uses: ./ + continue-on-error: true + with: + timeout_minutes: 1 + 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: true + actual: ${{ steps.retry_on_nonzero_fail.outputs.total_attempts == '2' && steps.retry_on_nonzero_fail.outcome == 'failure' && steps.retry_on_nonzero_fail.outputs.exit_code == '2' }} # runs on push to master only cd: diff --git a/action.yml b/action.yml index 1c51b68..f7ad823 100644 --- a/action.yml +++ b/action.yml @@ -19,6 +19,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, nonzero] +outputs: + total_attempts: + description: The final number of attempts made + exit_code: + description: The final exit code returned by the command + exit_error: + descritipn: The final error returned by the command runs: using: 'node12' - main: 'dist/index.js' \ No newline at end of file + main: 'dist/index.js' diff --git a/dist/index.js b/dist/index.js index 40ba9b2..1f08b60 100644 --- a/dist/index.js +++ b/dist/index.js @@ -414,12 +414,27 @@ 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', 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); +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); @@ -431,20 +446,15 @@ 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; + + exit = 0; + done = false; var child = spawn('node', [__webpack_require__.ab + "exec.js", COMMAND], { stdio: 'inherit' }); @@ -457,7 +467,7 @@ async function runCmd() { 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); @@ -465,7 +475,7 @@ async function runCmd() { throw new Error(`Timeout of ${TIMEOUT_MINUTES}m hit`); } else if (exit > 0) { await retryWait(); - throw new Error(`Child_process exited with error`); + throw new Error(`Child_process exited with error code ${exit}`); } else { return; } @@ -481,12 +491,20 @@ async function retryWait() { async function runAction() { 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 == 'nonzero') { + // error: timeout + throw error; + } else if (exit > 0 && RETRY_ON == 'timeout') { + // error: nonzero + throw error; } else { warning(`Attempt ${attempt} failed. Reason:`, error.message); } @@ -494,10 +512,21 @@ async function runAction() { } } -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); + }); /***/ }), diff --git a/src/index.js b/src/index.js index b2755f8..68b7586 100644 --- a/src/index.js +++ b/src/index.js @@ -1,9 +1,24 @@ -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', 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); +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); @@ -15,21 +30,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); -const RETRY_ON = getInput('retry_on') || 'both'; - async function wait(ms) { return new Promise((r) => setTimeout(r, ms)); } -var exit; -var done; - async function runCmd() { const end_time = Date.now() + ms.minutes(TIMEOUT_MINUTES); @@ -71,6 +75,8 @@ async function retryWait() { async function runAction() { 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; @@ -91,16 +97,17 @@ async function runAction() { } runAction() -.then(() => { - process.exit(0); // success -}) -.catch((err) => { - error(err.message); - if (exit > 0) { - // error: nonzero - process.exit(exit); // copy exit code - } else { - // error: Final attempt failed or timeout - process.exit(1); - } -}); + .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); + });