From 67e1bdfd8d84f11aefd370d7668c93e6d08e248c Mon Sep 17 00:00:00 2001 From: Nick Fields Date: Thu, 10 Jun 2021 18:08:08 -0400 Subject: [PATCH 1/2] minor: add continue_on_error input option --- .github/workflows/ci_cd.yml | 35 ++++++++ action.yml | 5 +- dist/index.js | 171 +++++++++++++++++++++++++++++++----- package-lock.json | 35 ++++---- package.json | 2 +- src/index.ts | 26 +++++- 6 files changed, 227 insertions(+), 47 deletions(-) diff --git a/.github/workflows/ci_cd.yml b/.github/workflows/ci_cd.yml index db5184c..7a2274f 100644 --- a/.github/workflows/ci_cd.yml +++ b/.github/workflows/ci_cd.yml @@ -97,6 +97,41 @@ jobs: 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: ./ diff --git a/action.yml b/action.yml index cc82eea..ada5748 100644 --- a/action.yml +++ b/action.yml @@ -33,6 +33,9 @@ inputs: 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 outputs: total_attempts: description: The final number of attempts made @@ -42,4 +45,4 @@ outputs: description: 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 cc4d08c..bc92e95 100644 --- a/dist/index.js +++ b/dist/index.js @@ -51,6 +51,7 @@ module.exports = // 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 @@ -65,6 +66,25 @@ function toCommandValue(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 /***/ }), @@ -82,14 +102,27 @@ module.exports = require("os"); "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 (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.issueCommand = void 0; // We use any as a valid input type /* eslint-disable @typescript-eslint/no-explicit-any */ const fs = __importStar(__webpack_require__(747)); @@ -254,6 +287,7 @@ var POLLING_INTERVAL_SECONDS = getInputNumber('polling_interval_seconds', false) 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 OS = process.platform; var OUTPUT_TOTAL_ATTEMPTS_KEY = 'total_attempts'; var OUTPUT_EXIT_CODE_KEY = 'exit_code'; @@ -272,6 +306,13 @@ function getInputNumber(id, required) { } 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; @@ -483,12 +524,20 @@ runAction() process.exit(0); // success }) .catch(function (err) { - core_1.error(err.message); + // 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, exit > 0 ? exit : 1); - // exit with exact error code if available, otherwise just exit with 1 - process.exit(exit > 0 ? exit : 1); + 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); }); @@ -499,14 +548,27 @@ runAction() "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); /** @@ -585,6 +647,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) { @@ -594,14 +675,8 @@ 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); @@ -668,7 +743,9 @@ function addPath(inputPath) { } 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. @@ -679,9 +756,49 @@ 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. * @@ -690,6 +807,7 @@ exports.getInput = getInput; */ // 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; @@ -736,19 +854,30 @@ exports.debug = debug; /** * Adds an error issue * @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 instanceof Error ? message.toString() : 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 + * 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 instanceof Error ? message.toString() : 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 diff --git a/package-lock.json b/package-lock.json index cad8c37..d4d6fe1 100644 --- a/package-lock.json +++ b/package-lock.json @@ -5,9 +5,9 @@ "requires": true, "dependencies": { "@actions/core": { - "version": "1.2.6", - "resolved": "https://registry.npmjs.org/@actions/core/-/core-1.2.6.tgz", - "integrity": "sha512-ZQYitnqiyBc3D+k7LsgSBmMDVkOVidaagDG7j3fOym77jNunWRuYx7VSHa9GNfFZh+zh61xsCjRj4JxMZlDqTA==" + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/@actions/core/-/core-1.5.0.tgz", + "integrity": "sha512-eDOLH1Nq9zh+PJlYLqEMkS/jLQxhksPNmUGNBHfa4G+tQmnIhzpctxmchETtVGyBOvXgOVVpYuE40+eS4cUnwQ==" }, "@babel/code-frame": { "version": "7.8.3", @@ -2367,9 +2367,9 @@ } }, "glob-parent": { - "version": "5.1.1", - "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.1.tgz", - "integrity": "sha512-FnI+VGOpnlGHWZxthPGR+QhR78fuiK0sNLkHQv+bL9fQi57lNNdquIbna/WrfROrolq8GK5Ek6BiMwqL/voRYQ==", + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", "dev": true, "requires": { "is-glob": "^4.0.1" @@ -2405,9 +2405,9 @@ "dev": true }, "handlebars": { - "version": "4.7.6", - "resolved": "https://registry.npmjs.org/handlebars/-/handlebars-4.7.6.tgz", - "integrity": "sha512-1f2BACcBfiwAfStCKZNrUCgqNZkGsAT7UM3kkYtXuLo0KnaVfjKOyf7PRzB6++aK9STyT1Pd2ZCPe3EGOXleXA==", + "version": "4.7.7", + "resolved": "https://registry.npmjs.org/handlebars/-/handlebars-4.7.7.tgz", + "integrity": "sha512-aAcXm5OAfE/8IXkcZvCepKU3VzW1/39Fb5ZuqMtgI/hT8X2YgoMvBY5dLhq/cpOvw7Lk1nK/UF71aLG/ZnVYRA==", "dev": true, "requires": { "minimist": "^1.2.5", @@ -2818,9 +2818,9 @@ } }, "lodash": { - "version": "4.17.20", - "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.20.tgz", - "integrity": "sha512-PlhdFcillOINfeV7Ni6oF1TAEayyZBoZ8bcshTHqOYJYlrqzRK5hagpagky5o4HfCzzd1TRkXPMFq6cKk9rGmA==", + "version": "4.17.21", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz", + "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==", "dev": true }, "lodash._reinterpolate": { @@ -6529,11 +6529,6 @@ "bundled": true, "dev": true }, - "y18n": { - "version": "4.0.0", - "bundled": true, - "dev": true - }, "yallist": { "version": "3.0.3", "bundled": true, @@ -7740,9 +7735,9 @@ "dev": true }, "y18n": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/y18n/-/y18n-4.0.0.tgz", - "integrity": "sha512-r9S/ZyXu/Xu9q1tYlpsLIsa3EeLXXk0VwlxqTcFRfg9EhMW+17kbt9G0NrgCmhGb5vT2hyhJZLfDGx+7+5Uj/w==", + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/y18n/-/y18n-4.0.1.tgz", + "integrity": "sha512-wNcy4NvjMYL8gogWWYAO7ZFWFfHcbdbE57tZO8e4cbpj8tfUcwrwqSl3ad8HxpYWCdXcJUCeKKZS62Av1affwQ==", "dev": true }, "yallist": { diff --git a/package.json b/package.json index a754e0c..149234b 100644 --- a/package.json +++ b/package.json @@ -18,7 +18,7 @@ }, "homepage": "https://github.com/nick-invision/retry#readme", "dependencies": { - "@actions/core": "^1.2.6", + "@actions/core": "^1.5.0", "milliseconds": "^1.0.3", "tree-kill": "^1.2.2" }, diff --git a/src/index.ts b/src/index.ts index b9ac3d7..947ad0d 100644 --- a/src/index.ts +++ b/src/index.ts @@ -16,6 +16,7 @@ const POLLING_INTERVAL_SECONDS = getInputNumber('polling_interval_seconds', fals 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 OS = process.platform; const OUTPUT_TOTAL_ATTEMPTS_KEY = 'total_attempts'; @@ -41,6 +42,15 @@ function getInputNumber(id: string, required: boolean): number | undefined { 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)); @@ -195,12 +205,20 @@ runAction() process.exit(0); // success }) .catch((err) => { - error(err.message); + // 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, exit > 0 ? exit : 1); + setOutput(OUTPUT_EXIT_CODE_KEY, exitCode); - // exit with exact error code if available, otherwise just exit with 1 - process.exit(exit > 0 ? exit : 1); + // 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); }); From 001981184657626bd3e80416096b9a24d48f05e5 Mon Sep 17 00:00:00 2001 From: Nick Fields Date: Thu, 23 Sep 2021 22:36:47 -0400 Subject: [PATCH 2/2] docs: update README with new input and usage --- README.md | 28 +++++++++++++++++++++++++++- 1 file changed, 27 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 37280da..0486a81 100644 --- a/README.md +++ b/README.md @@ -44,6 +44,10 @@ Retries an Action step on failure or timeout. This is currently intended to repl **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. +### `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` @@ -113,7 +117,29 @@ with: command: npm run some-typically-fast-script ``` -### Retry but allow failure and do something with output +### 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