diff --git a/.github/workflows/itself.yml b/.github/workflows/itself.yml index 19069c7c..29adc79d 100644 --- a/.github/workflows/itself.yml +++ b/.github/workflows/itself.yml @@ -27,7 +27,7 @@ jobs: - uses: actions/checkout@9bb56186c3b09b4f86b1c65136769dd318469633 # v4.1.2 # Do NOT specify any options here to make sure zero config may work - uses: ./ - exponential_backoff: + validation_example_basic_errors_allow_failure: runs-on: ubuntu-latest if: ${{ github.actor != 'dependabot[bot]' && github.actor != 'renovate[bot]' }} timeout-minutes: 5 @@ -36,10 +36,50 @@ jobs: - uses: ./ env: ACTIONS_STEP_DEBUG: true + # Should allow failures in this job + continue-on-error: true + with: + retry-method: 'unknown_method' + min-interval-seconds: '-1' + attempt-limits: '0' + validation_example_combination_errors_allow_failure: + runs-on: ubuntu-latest + if: ${{ github.actor != 'dependabot[bot]' && github.actor != 'renovate[bot]' }} + timeout-minutes: 5 + steps: + - uses: actions/checkout@9bb56186c3b09b4f86b1c65136769dd318469633 # v4.1.2 + - uses: ./ + env: + ACTIONS_STEP_DEBUG: true + # Should allow failures in this job + continue-on-error: true + with: + wait-list: | + [ + { + "workflowFile": "lint.yml" + } + ] + skip-list: | + [ + { + "workflowFile": "release.yml" + } + ] + exponential_backoff_allow_failure: + runs-on: ubuntu-latest + if: ${{ github.actor != 'dependabot[bot]' && github.actor != 'renovate[bot]' }} + timeout-minutes: 5 + steps: + - uses: actions/checkout@9bb56186c3b09b4f86b1c65136769dd318469633 # v4.1.2 + - uses: ./ + env: + ACTIONS_STEP_DEBUG: true + # With the algorithm, this job takes long minutes to wait completed other jobs + # So set small limit to stop faster. continue-on-error: true with: retry-method: 'exponential_backoff' - # Set low intervals but stop faster. Returning false is an intentional only in this job wait-seconds-before-first-polling: 2 min-interval-seconds: 2 attempt-limits: 2 diff --git a/dist/index.js b/dist/index.js index f670f673..5c968b21 100644 --- a/dist/index.js +++ b/dist/index.js @@ -737,7 +737,7 @@ var require_tunnel = __commonJS({ connectOptions.headers = connectOptions.headers || {}; connectOptions.headers["Proxy-Authorization"] = "Basic " + new Buffer(connectOptions.proxyAuth).toString("base64"); } - debug2("making CONNECT request"); + debug3("making CONNECT request"); var connectReq = self.request(connectOptions); connectReq.useChunkedEncodingByDefault = false; connectReq.once("response", onResponse); @@ -757,7 +757,7 @@ var require_tunnel = __commonJS({ connectReq.removeAllListeners(); socket.removeAllListeners(); if (res.statusCode !== 200) { - debug2( + debug3( "tunneling socket could not be established, statusCode=%d", res.statusCode ); @@ -769,7 +769,7 @@ var require_tunnel = __commonJS({ return; } if (head.length > 0) { - debug2("got illegal response body from proxy"); + debug3("got illegal response body from proxy"); socket.destroy(); var error2 = new Error("got illegal response body from proxy"); error2.code = "ECONNRESET"; @@ -777,13 +777,13 @@ var require_tunnel = __commonJS({ self.removeSocket(placeholder); return; } - debug2("tunneling connection has established"); + debug3("tunneling connection has established"); self.sockets[self.sockets.indexOf(placeholder)] = socket; return cb(socket); } function onError(cause) { connectReq.removeAllListeners(); - debug2( + debug3( "tunneling socket could not be established, cause=%s\n", cause.message, cause.stack @@ -845,9 +845,9 @@ var require_tunnel = __commonJS({ } return target; } - var debug2; + var debug3; if (process.env.NODE_DEBUG && /\btunnel\b/.test(process.env.NODE_DEBUG)) { - debug2 = function() { + debug3 = function() { var args = Array.prototype.slice.call(arguments); if (typeof args[0] === "string") { args[0] = "TUNNEL: " + args[0]; @@ -857,10 +857,10 @@ var require_tunnel = __commonJS({ console.error.apply(console, args); }; } else { - debug2 = function() { + debug3 = function() { }; } - exports.debug = debug2; + exports.debug = debug3; } }); @@ -18953,14 +18953,14 @@ Support boolean input list: \`true | True | TRUE | false | False | FALSE\``); error2(message); } exports.setFailed = setFailed2; - function isDebug2() { + function isDebug3() { return process.env["RUNNER_DEBUG"] === "1"; } - exports.isDebug = isDebug2; - function debug2(message) { + exports.isDebug = isDebug3; + function debug3(message) { command_1.issueCommand("debug", {}, message); } - exports.debug = debug2; + exports.debug = debug3; function error2(message, properties = {}) { command_1.issueCommand("error", utils_1.toCommandProperties(properties), message instanceof Error ? message.toString() : message); } @@ -23117,8 +23117,7 @@ var require_github = __commonJS({ }); // src/main.ts -var import_core2 = __toESM(require_core(), 1); -var import_github = __toESM(require_github(), 1); +var import_core3 = __toESM(require_core(), 1); // node_modules/.pnpm/ansi-styles@6.2.1/node_modules/ansi-styles/index.js var ANSI_BACKGROUND_OFFSET = 10; @@ -23306,6 +23305,10 @@ function assembleStyles() { var ansiStyles = assembleStyles(); var ansi_styles_default = ansiStyles; +// src/input.ts +var import_core = __toESM(require_core(), 1); +var import_github = __toESM(require_github(), 1); + // node_modules/.pnpm/zod@3.22.4/node_modules/zod/lib/index.mjs var util; (function(util2) { @@ -27045,52 +27048,78 @@ var FilterCondition = z.object({ jobName: z.string().min(1).optional() }); var SkipFilterCondition = FilterCondition.readonly(); -var SkipFilterConditions = z.array(SkipFilterCondition).readonly(); var WaitFilterCondition = FilterCondition.extend( { optional: z.boolean().optional().default(false).readonly() } ).readonly(); -var WaitFilterConditions = z.array(WaitFilterCondition).readonly(); +var retryMethods = z.enum(["exponential_backoff", "equal_intervals"]); +var Options = z.object({ + waitList: z.array(WaitFilterCondition).readonly(), + skipList: z.array(SkipFilterCondition).readonly(), + waitSecondsBeforeFirstPolling: z.number().min(0), + minIntervalSeconds: z.number().min(1), + retryMethod: retryMethods, + attemptLimits: z.number().min(1), + isEarlyExit: z.boolean(), + shouldSkipSameWorkflow: z.boolean(), + isDryRun: z.boolean() +}).readonly().refine( + ({ waitList, skipList }) => !(waitList.length > 0 && skipList.length > 0), + { message: "Do not specify both wait-list and skip-list", path: ["waitList", "skipList"] } +); -// src/wait.ts -import { setTimeout as setTimeout2 } from "timers/promises"; -var wait = setTimeout2; -var retryMethods = ["exponential_backoff", "equal_intervals"]; -var isRetryMethod = (method) => [...retryMethods].includes(method); -function getRandomInt(min, max) { - const flooredMin = Math.ceil(min); - return Math.floor(Math.random() * (Math.floor(max) - flooredMin) + flooredMin); -} -function readableDuration(milliseconds) { - const msecToSec = 1e3; - const secToMin = 60; - const seconds = milliseconds / msecToSec; - const minutes = seconds / secToMin; - const { unit, value, precision } = minutes >= 1 ? { unit: "minutes", value: minutes, precision: 1 } : { unit: "seconds", value: seconds, precision: 0 }; - const adjustor = 10 ** precision; - return `about ${(Math.round(value * adjustor) / adjustor).toFixed( - precision - )} ${unit}`; -} -var MIN_JITTER_MILLISECONDS = 1e3; -var MAX_JITTER_MILLISECONDS = 7e3; -function calcExponentialBackoffAndJitter(minIntervalSeconds, attempts) { - const jitterMilliseconds = getRandomInt(MIN_JITTER_MILLISECONDS, MAX_JITTER_MILLISECONDS); - return minIntervalSeconds * 2 ** (attempts - 1) * 1e3 + jitterMilliseconds; -} -function getIdleMilliseconds(method, minIntervalSeconds, attempts) { - switch (method) { - case "exponential_backoff": - return calcExponentialBackoffAndJitter( - minIntervalSeconds, - attempts - ); - case "equal_intervals": - return minIntervalSeconds * 1e3; - default: { - const _exhaustiveCheck = method; - return minIntervalSeconds * 1e3; +// src/input.ts +function parseInput() { + const { + repo, + payload, + runId, + job, + sha + } = import_github.context; + const pr = payload.pull_request; + let commitSha = sha; + if (pr) { + const { head: { sha: prSha = sha } } = pr; + if (typeof prSha === "string") { + commitSha = prSha; + } else { + if ((0, import_core.isDebug)()) { + (0, import_core.debug)(JSON.stringify(pr, null, 2)); + } + (0, import_core.error)("github context has unexpected format: missing context.payload.pull_request.head.sha"); } } + const waitSecondsBeforeFirstPolling = parseInt( + (0, import_core.getInput)("wait-seconds-before-first-polling", { required: true, trimWhitespace: true }), + 10 + ); + const minIntervalSeconds = parseInt( + (0, import_core.getInput)("min-interval-seconds", { required: true, trimWhitespace: true }), + 10 + ); + const retryMethod = (0, import_core.getInput)("retry-method", { required: true, trimWhitespace: true }); + const attemptLimits = parseInt( + (0, import_core.getInput)("attempt-limits", { required: true, trimWhitespace: true }), + 10 + ); + const isEarlyExit = (0, import_core.getBooleanInput)("early-exit", { required: true, trimWhitespace: true }); + const shouldSkipSameWorkflow = (0, import_core.getBooleanInput)("skip-same-workflow", { required: true, trimWhitespace: true }); + const isDryRun = (0, import_core.getBooleanInput)("dry-run", { required: true, trimWhitespace: true }); + const options = Options.parse({ + waitSecondsBeforeFirstPolling, + minIntervalSeconds, + retryMethod, + attemptLimits, + waitList: JSON.parse((0, import_core.getInput)("wait-list", { required: true })), + skipList: JSON.parse((0, import_core.getInput)("skip-list", { required: true })), + isEarlyExit, + shouldSkipSameWorkflow, + isDryRun + }); + const trigger = { ...repo, ref: commitSha, runId, jobName: job }; + const githubToken = (0, import_core.getInput)("github-token", { required: true, trimWhitespace: false }); + (0, import_core.setSecret)(githubToken); + return { trigger, options, githubToken }; } // node_modules/.pnpm/universal-user-agent@7.0.2/node_modules/universal-user-agent/index.js @@ -28290,10 +28319,7 @@ function summarize(check, trigger) { runConclusion: run2.conclusion }; } -function generateReport(checks, trigger, waitList, skipList, shouldSkipSameWorkflow) { - if (waitList.length > 0 && skipList.length > 0) { - throw new Error("Do not specify both wait-list and skip-list"); - } +function generateReport(checks, trigger, { waitList, skipList, shouldSkipSameWorkflow }) { const summaries = checks.map((check) => summarize(check, trigger)).toSorted( (a, b) => join(a.workflowPath, a.jobName).localeCompare(join(b.workflowPath, b.jobName)) ); @@ -28329,127 +28355,92 @@ function generateReport(checks, trigger, waitList, skipList, shouldSkipSameWorkf return { progress, conclusion, summaries: filtered }; } +// src/wait.ts +import { setTimeout as setTimeout2 } from "timers/promises"; +var wait = setTimeout2; +function getRandomInt(min, max) { + const flooredMin = Math.ceil(min); + return Math.floor(Math.random() * (Math.floor(max) - flooredMin) + flooredMin); +} +function readableDuration(milliseconds) { + const msecToSec = 1e3; + const secToMin = 60; + const seconds = milliseconds / msecToSec; + const minutes = seconds / secToMin; + const { unit, value, precision } = minutes >= 1 ? { unit: "minutes", value: minutes, precision: 1 } : { unit: "seconds", value: seconds, precision: 0 }; + const adjustor = 10 ** precision; + return `about ${(Math.round(value * adjustor) / adjustor).toFixed( + precision + )} ${unit}`; +} +var MIN_JITTER_MILLISECONDS = 1e3; +var MAX_JITTER_MILLISECONDS = 7e3; +function calcExponentialBackoffAndJitter(minIntervalSeconds, attempts) { + const jitterMilliseconds = getRandomInt(MIN_JITTER_MILLISECONDS, MAX_JITTER_MILLISECONDS); + return minIntervalSeconds * 2 ** (attempts - 1) * 1e3 + jitterMilliseconds; +} +function getIdleMilliseconds(method, minIntervalSeconds, attempts) { + switch (method) { + case "exponential_backoff": + return calcExponentialBackoffAndJitter( + minIntervalSeconds, + attempts + ); + case "equal_intervals": + return minIntervalSeconds * 1e3; + default: { + const _exhaustiveCheck = method; + return minIntervalSeconds * 1e3; + } + } +} + // src/main.ts var errorMessage = (body) => `${ansi_styles_default.red.open}${body}${ansi_styles_default.red.close}`; var succeededMessage = (body) => `${ansi_styles_default.green.open}${body}${ansi_styles_default.green.close}`; var colorize = (body, ok) => ok ? succeededMessage(body) : errorMessage(body); async function run() { - (0, import_core2.startGroup)("Parameters"); - const { - repo: { repo, owner }, - payload, - runId, - runNumber, - // Another file can set same workflow name. So you should filter workfrows from runId or the filename - workflow, - // On the otherhand, jobName should be unique in each workflow from YAML spec - job, - sha - } = import_github.context; - const pr = payload.pull_request; - let commitSha = sha; - if (pr) { - const { head: { sha: prSha = sha } } = pr; - if (typeof prSha === "string") { - commitSha = prSha; - } else { - if ((0, import_core2.isDebug)()) { - (0, import_core2.debug)(JSON.stringify(pr, null, 2)); - } - (0, import_core2.error)("github context has unexpected format: missing context.payload.pull_request.head.sha"); - (0, import_core2.setFailed)("unexpected failure occurred"); - return; - } - } - const repositoryInfo = { - owner, - repo - }; - const waitSecondsBeforeFirstPolling = parseInt( - (0, import_core2.getInput)("wait-seconds-before-first-polling", { required: true, trimWhitespace: true }), - 10 - ); - const minIntervalSeconds = parseInt( - (0, import_core2.getInput)("min-interval-seconds", { required: true, trimWhitespace: true }), - 10 - ); - const retryMethod = (0, import_core2.getInput)("retry-method", { required: true, trimWhitespace: true }); - if (!isRetryMethod(retryMethod)) { - (0, import_core2.setFailed)( - `unknown parameter "${retryMethod}" is given. "retry-method" can take one of ${JSON.stringify(retryMethods)}` - ); - return; - } - const attemptLimits = parseInt( - (0, import_core2.getInput)("attempt-limits", { required: true, trimWhitespace: true }), - 10 - ); - const waitList = WaitFilterConditions.parse(JSON.parse((0, import_core2.getInput)("wait-list", { required: true }))); - const skipList = SkipFilterConditions.parse(JSON.parse((0, import_core2.getInput)("skip-list", { required: true }))); - if (waitList.length > 0 && skipList.length > 0) { - (0, import_core2.error)("Do not specify both wait-list and skip-list"); - (0, import_core2.setFailed)("Specified both list"); - } - const isEarlyExit = (0, import_core2.getBooleanInput)("early-exit", { required: true, trimWhitespace: true }); - const shouldSkipSameWorkflow = (0, import_core2.getBooleanInput)("skip-same-workflow", { required: true, trimWhitespace: true }); - const trigger = { ...repositoryInfo, ref: commitSha, runId, jobName: job }; - const isDryRun = (0, import_core2.getBooleanInput)("dry-run", { required: true, trimWhitespace: true }); - (0, import_core2.info)(JSON.stringify( + (0, import_core3.startGroup)("Parameters"); + const { trigger, options, githubToken } = parseInput(); + (0, import_core3.info)(JSON.stringify( { - triggeredCommitSha: commitSha, - runId, - runNumber, - workflow, - job, - repositoryInfo, - waitSecondsBeforeFirstPolling, - minIntervalSeconds, - retryMethod, - attemptLimits, - isEarlyExit, - isDryRun, - waitList, - skipList, - shouldSkipSameWorkflow - // Of course, do NOT include tokens here. + trigger, + options + // Do NOT include secrets }, null, 2 )); - const githubToken = (0, import_core2.getInput)("github-token", { required: true, trimWhitespace: false }); - (0, import_core2.setSecret)(githubToken); + (0, import_core3.endGroup)(); let attempts = 0; let shouldStop = false; - (0, import_core2.endGroup)(); - if (isDryRun) { + if (options.isDryRun) { return; } for (; ; ) { attempts += 1; - if (attempts > attemptLimits) { - (0, import_core2.setFailed)(errorMessage(`reached to given attempt limits "${attemptLimits}"`)); + if (attempts > options.attemptLimits) { + (0, import_core3.setFailed)(errorMessage(`reached to given attempt limits "${options.attemptLimits}"`)); break; } if (attempts === 1) { - const initialMsec = waitSecondsBeforeFirstPolling * 1e3; - (0, import_core2.info)(`Wait ${readableDuration(initialMsec)} before first polling.`); + const initialMsec = options.waitSecondsBeforeFirstPolling * 1e3; + (0, import_core3.info)(`Wait ${readableDuration(initialMsec)} before first polling.`); await wait(initialMsec); } else { - const msec = getIdleMilliseconds(retryMethod, minIntervalSeconds, attempts); - (0, import_core2.info)(`Wait ${readableDuration(msec)} before next polling to reduce API calls.`); + const msec = getIdleMilliseconds(options.retryMethod, options.minIntervalSeconds, attempts); + (0, import_core3.info)(`Wait ${readableDuration(msec)} before next polling to reduce API calls.`); await wait(msec); } - (0, import_core2.startGroup)(`Polling ${attempts}: ${(/* @__PURE__ */ new Date()).toISOString()}`); + (0, import_core3.startGroup)(`Polling ${attempts}: ${(/* @__PURE__ */ new Date()).toISOString()}`); const checks = await fetchChecks(githubToken, trigger); - if ((0, import_core2.isDebug)()) { - (0, import_core2.debug)(JSON.stringify(checks, null, 2)); + if ((0, import_core3.isDebug)()) { + (0, import_core3.debug)(JSON.stringify(checks, null, 2)); } const report = generateReport( checks, trigger, - waitList, - skipList, - shouldSkipSameWorkflow + options ); for (const summary of report.summaries) { const { @@ -28463,37 +28454,37 @@ async function run() { checkRunUrl } = summary; const nullStr = "(null)"; - (0, import_core2.info)( + (0, import_core3.info)( `${workflowPath}(${colorize(`${jobName}`, acceptable)}): [suiteStatus: ${checkSuiteStatus}][suiteConclusion: ${checkSuiteConclusion ?? nullStr}][runStatus: ${runStatus}][runConclusion: ${runConclusion ?? nullStr}][runURL: ${checkRunUrl}]` ); } - if ((0, import_core2.isDebug)()) { - (0, import_core2.debug)(JSON.stringify(report, null, 2)); + if ((0, import_core3.isDebug)()) { + (0, import_core3.debug)(JSON.stringify(report, null, 2)); } const { progress, conclusion } = report; switch (progress) { case "in_progress": { - if (conclusion === "bad" && isEarlyExit) { + if (conclusion === "bad" && options.isEarlyExit) { shouldStop = true; - (0, import_core2.setFailed)(errorMessage("some jobs failed")); + (0, import_core3.setFailed)(errorMessage("some jobs failed")); } - (0, import_core2.info)("some jobs still in progress"); + (0, import_core3.info)("some jobs still in progress"); break; } case "done": { shouldStop = true; switch (conclusion) { case "acceptable": { - (0, import_core2.info)(succeededMessage("all jobs passed")); + (0, import_core3.info)(succeededMessage("all jobs passed")); break; } case "bad": { - (0, import_core2.setFailed)(errorMessage("some jobs failed")); + (0, import_core3.setFailed)(errorMessage("some jobs failed")); break; } default: { const unexpectedConclusion = conclusion; - (0, import_core2.setFailed)(errorMessage(`got unexpected conclusion: ${unexpectedConclusion}`)); + (0, import_core3.setFailed)(errorMessage(`got unexpected conclusion: ${unexpectedConclusion}`)); break; } } @@ -28502,11 +28493,11 @@ async function run() { default: { shouldStop = true; const unexpectedProgress = progress; - (0, import_core2.setFailed)(errorMessage(`got unexpected progress: ${unexpectedProgress}`)); + (0, import_core3.setFailed)(errorMessage(`got unexpected progress: ${unexpectedProgress}`)); break; } } - (0, import_core2.endGroup)(); + (0, import_core3.endGroup)(); if (shouldStop) { break; } diff --git a/src/input.ts b/src/input.ts new file mode 100644 index 00000000..f74ccd52 --- /dev/null +++ b/src/input.ts @@ -0,0 +1,65 @@ +import { debug, getInput, getBooleanInput, setSecret, isDebug, error } from '@actions/core'; +import { context } from '@actions/github'; + +import { Options, Trigger } from './schema.js'; + +export function parseInput(): { trigger: Trigger; options: Options; githubToken: string } { + const { + repo, + payload, + runId, + job, + sha, + } = context; + const pr = payload.pull_request; + let commitSha = sha; + if (pr) { + const { head: { sha: prSha = sha } } = pr; + if (typeof prSha === 'string') { + commitSha = prSha; + } else { + if (isDebug()) { + // Do not print secret even for debug code + debug(JSON.stringify(pr, null, 2)); + } + error('github context has unexpected format: missing context.payload.pull_request.head.sha'); + } + } + + const waitSecondsBeforeFirstPolling = parseInt( + getInput('wait-seconds-before-first-polling', { required: true, trimWhitespace: true }), + 10, + ); + const minIntervalSeconds = parseInt( + getInput('min-interval-seconds', { required: true, trimWhitespace: true }), + 10, + ); + const retryMethod = getInput('retry-method', { required: true, trimWhitespace: true }); + const attemptLimits = parseInt( + getInput('attempt-limits', { required: true, trimWhitespace: true }), + 10, + ); + const isEarlyExit = getBooleanInput('early-exit', { required: true, trimWhitespace: true }); + const shouldSkipSameWorkflow = getBooleanInput('skip-same-workflow', { required: true, trimWhitespace: true }); + const isDryRun = getBooleanInput('dry-run', { required: true, trimWhitespace: true }); + + const options = Options.parse({ + waitSecondsBeforeFirstPolling, + minIntervalSeconds, + retryMethod, + attemptLimits, + waitList: JSON.parse(getInput('wait-list', { required: true })), + skipList: JSON.parse(getInput('skip-list', { required: true })), + isEarlyExit, + shouldSkipSameWorkflow, + isDryRun, + }); + + const trigger = { ...repo, ref: commitSha, runId, jobName: job } as const satisfies Trigger; + + // `getIDToken` does not fit for this purpose. It is provided for OIDC Token + const githubToken = getInput('github-token', { required: true, trimWhitespace: false }); + setSecret(githubToken); + + return { trigger, options, githubToken }; +} diff --git a/src/main.ts b/src/main.ts index 83ccd56e..4cf62afc 100644 --- a/src/main.ts +++ b/src/main.ts @@ -1,140 +1,47 @@ -import { - debug, - info, - getInput, - getBooleanInput, - setSecret, - setFailed, - isDebug, - startGroup, - endGroup, - error, -} from '@actions/core'; -import { context } from '@actions/github'; +import { debug, info, setFailed, isDebug, startGroup, endGroup } from '@actions/core'; import styles from 'ansi-styles'; const errorMessage = (body: string) => (`${styles.red.open}${body}${styles.red.close}`); const succeededMessage = (body: string) => (`${styles.green.open}${body}${styles.green.close}`); const colorize = (body: string, ok: boolean) => (ok ? succeededMessage(body) : errorMessage(body)); -import { SkipFilterConditions, Trigger, WaitFilterConditions } from './schema.js'; -import { readableDuration, wait, isRetryMethod, retryMethods, getIdleMilliseconds } from './wait.js'; +import { parseInput } from './input.ts'; import { fetchChecks } from './github-api.ts'; import { generateReport } from './report.ts'; +import { readableDuration, wait, getIdleMilliseconds } from './wait.ts'; async function run(): Promise { startGroup('Parameters'); - const { - repo: { repo, owner }, - payload, - runId, - runNumber, - // Another file can set same workflow name. So you should filter workfrows from runId or the filename - workflow, - // On the otherhand, jobName should be unique in each workflow from YAML spec - job, - sha, - } = context; - const pr = payload.pull_request; - let commitSha = sha; - if (pr) { - const { head: { sha: prSha = sha } } = pr; - if (typeof prSha === 'string') { - commitSha = prSha; - } else { - if (isDebug()) { - // Do not print secret even for debug code - debug(JSON.stringify(pr, null, 2)); - } - error('github context has unexpected format: missing context.payload.pull_request.head.sha'); - setFailed('unexpected failure occurred'); - return; - } - } - - const repositoryInfo = { - owner, - repo, - } as const; - - const waitSecondsBeforeFirstPolling = parseInt( - getInput('wait-seconds-before-first-polling', { required: true, trimWhitespace: true }), - 10, - ); - const minIntervalSeconds = parseInt( - getInput('min-interval-seconds', { required: true, trimWhitespace: true }), - 10, - ); - const retryMethod = getInput('retry-method', { required: true, trimWhitespace: true }); - if (!isRetryMethod(retryMethod)) { - setFailed( - `unknown parameter "${retryMethod}" is given. "retry-method" can take one of ${JSON.stringify(retryMethods)}`, - ); - return; - } - const attemptLimits = parseInt( - getInput('attempt-limits', { required: true, trimWhitespace: true }), - 10, - ); - const waitList = WaitFilterConditions.parse(JSON.parse(getInput('wait-list', { required: true }))); - const skipList = SkipFilterConditions.parse(JSON.parse(getInput('skip-list', { required: true }))); - if (waitList.length > 0 && skipList.length > 0) { - error('Do not specify both wait-list and skip-list'); - setFailed('Specified both list'); - } - const isEarlyExit = getBooleanInput('early-exit', { required: true, trimWhitespace: true }); - const shouldSkipSameWorkflow = getBooleanInput('skip-same-workflow', { required: true, trimWhitespace: true }); - const trigger = { ...repositoryInfo, ref: commitSha, runId, jobName: job } as const satisfies Trigger; - const isDryRun = getBooleanInput('dry-run', { required: true, trimWhitespace: true }); - + const { trigger, options, githubToken } = parseInput(); info(JSON.stringify( { - triggeredCommitSha: commitSha, - runId, - runNumber, - workflow, - job, - repositoryInfo, - waitSecondsBeforeFirstPolling, - minIntervalSeconds, - retryMethod, - attemptLimits, - isEarlyExit, - isDryRun, - waitList, - skipList, - shouldSkipSameWorkflow, - // Of course, do NOT include tokens here. + trigger, + options, // Do NOT include secrets }, null, 2, )); - - // `getIDToken` does not fit for this purpose. It is provided for OIDC Token - const githubToken = getInput('github-token', { required: true, trimWhitespace: false }); - setSecret(githubToken); + endGroup(); let attempts = 0; let shouldStop = false; - endGroup(); - - if (isDryRun) { + if (options.isDryRun) { return; } for (;;) { attempts += 1; - if (attempts > attemptLimits) { - setFailed(errorMessage(`reached to given attempt limits "${attemptLimits}"`)); + if (attempts > options.attemptLimits) { + setFailed(errorMessage(`reached to given attempt limits "${options.attemptLimits}"`)); break; } if (attempts === 1) { - const initialMsec = waitSecondsBeforeFirstPolling * 1000; + const initialMsec = options.waitSecondsBeforeFirstPolling * 1000; info(`Wait ${readableDuration(initialMsec)} before first polling.`); await wait(initialMsec); } else { - const msec = getIdleMilliseconds(retryMethod, minIntervalSeconds, attempts); + const msec = getIdleMilliseconds(options.retryMethod, options.minIntervalSeconds, attempts); info(`Wait ${readableDuration(msec)} before next polling to reduce API calls.`); await wait(msec); } @@ -147,9 +54,7 @@ async function run(): Promise { const report = generateReport( checks, trigger, - waitList, - skipList, - shouldSkipSameWorkflow, + options, ); for (const summary of report.summaries) { @@ -180,7 +85,7 @@ async function run(): Promise { switch (progress) { case 'in_progress': { - if (conclusion === 'bad' && isEarlyExit) { + if (conclusion === 'bad' && options.isEarlyExit) { shouldStop = true; setFailed(errorMessage('some jobs failed')); } diff --git a/src/report.test.ts b/src/report.test.ts index b514580f..0b1896ca 100644 --- a/src/report.test.ts +++ b/src/report.test.ts @@ -25,23 +25,25 @@ test('wait-list', () => { ref: '760074f4f419b55cb864030c29ece58a689a42a2', jobName: 'wait-list', }, - [ - { - 'workflowFile': 'lint.yml', - 'optional': false, - }, - { - 'workflowFile': 'merge-bot-pr.yml', - 'jobName': 'dependabot', - 'optional': true, - }, - { - 'workflowFile': 'THERE_ARE_NO_FILES_AS_THIS.yml', - 'optional': true, - }, - ], - [], - false, + { + waitList: [ + { + 'workflowFile': 'lint.yml', + 'optional': false, + }, + { + 'workflowFile': 'merge-bot-pr.yml', + 'jobName': 'dependabot', + 'optional': true, + }, + { + 'workflowFile': 'THERE_ARE_NO_FILES_AS_THIS.yml', + 'optional': true, + }, + ], + skipList: [], + shouldSkipSameWorkflow: false, + }, ); assert.deepStrictEqual({ @@ -98,23 +100,25 @@ test('skip-list', () => { ref: '760074f4f419b55cb864030c29ece58a689a42a2', jobName: 'skip-list', }, - [], - [ - { - 'workflowFile': 'itself.yml', - }, - { - 'workflowFile': 'ci.yml', - }, - { - 'workflowFile': 'ci-nix.yml', - }, - { - 'workflowFile': 'merge-bot-pr.yml', - 'jobName': 'dependabot', - }, - ], - false, + { + waitList: [], + skipList: [ + { + 'workflowFile': 'itself.yml', + }, + { + 'workflowFile': 'ci.yml', + }, + { + 'workflowFile': 'ci-nix.yml', + }, + { + 'workflowFile': 'merge-bot-pr.yml', + 'jobName': 'dependabot', + }, + ], + shouldSkipSameWorkflow: false, + }, ); assert.deepEqual({ diff --git a/src/report.ts b/src/report.ts index 7c4e3d5d..02317f37 100644 --- a/src/report.ts +++ b/src/report.ts @@ -1,5 +1,5 @@ import { CheckRun, CheckSuite } from '@octokit/graphql-schema'; -import { Check, SkipFilterConditions, Trigger, WaitFilterConditions } from './schema.js'; +import { Check, Options, Trigger } from './schema.js'; import { join, relative } from 'path'; interface Summary { @@ -47,14 +47,8 @@ function summarize(check: Check, trigger: Trigger): Summary { export function generateReport( checks: readonly Check[], trigger: Trigger, - waitList: WaitFilterConditions, - skipList: SkipFilterConditions, - shouldSkipSameWorkflow: boolean, + { waitList, skipList, shouldSkipSameWorkflow }: Pick, ): Report { - if (waitList.length > 0 && skipList.length > 0) { - throw new Error('Do not specify both wait-list and skip-list'); - } - const summaries = checks.map((check) => summarize(check, trigger)).toSorted((a, b) => join(a.workflowPath, a.jobName).localeCompare(join(b.workflowPath, b.jobName)) ); diff --git a/src/schema.test.ts b/src/schema.test.ts new file mode 100644 index 00000000..53eb37b3 --- /dev/null +++ b/src/schema.test.ts @@ -0,0 +1,76 @@ +import test from 'node:test'; +import assert from 'node:assert'; +import { Options } from './schema.ts'; + +const defaultOptions = Object.freeze({ + isEarlyExit: true, + attemptLimits: 1000, + waitList: [], + skipList: [], + waitSecondsBeforeFirstPolling: 10, + minIntervalSeconds: 15, + retryMethod: 'equal_intervals', + shouldSkipSameWorkflow: false, + isDryRun: false, +}); + +test('Options keep given values', () => { + assert.deepStrictEqual({ + isEarlyExit: true, + attemptLimits: 1000, + waitList: [], + skipList: [], + waitSecondsBeforeFirstPolling: 10, + minIntervalSeconds: 15, + retryMethod: 'equal_intervals', + shouldSkipSameWorkflow: false, + isDryRun: false, + }, Options.parse(defaultOptions)); +}); + +test('Options set some default values it cannot be defined in action.yml', () => { + assert.deepStrictEqual({ + ...defaultOptions, + waitList: [{ workflowFile: 'ci.yml', optional: false }], + }, Options.parse({ ...defaultOptions, waitList: [{ workflowFile: 'ci.yml' }] })); +}); + +test('Options reject invalid values', () => { + assert.throws(() => Options.parse({ ...defaultOptions, minIntervalSeconds: 0 }), { + name: 'ZodError', + message: /too_small/, + }); + + assert.throws(() => Options.parse({ ...defaultOptions, attemptLimits: 0 }), { + name: 'ZodError', + message: /too_small/, + }); + + assert.throws(() => Options.parse({ ...defaultOptions, retryMethod: 'inverse-exponential-backoff' }), { + name: 'ZodError', + message: /invalid_enum_value/, + }); + + assert.throws(() => Options.parse({ ...defaultOptions, waitList: [{ unknownField: ':)' }] }), { + name: 'ZodError', + message: /invalid_type/, + }); + + assert.throws(() => Options.parse({ ...defaultOptions, skipList: [{ optional: true }] }), { + name: 'ZodError', + message: /invalid_type/, + }); + + assert.throws( + () => + Options.parse({ + ...defaultOptions, + waitList: [{ workflowFile: 'ci.yml' }], + skipList: [{ workflowFile: 'release.yml' }], + }), + { + name: 'ZodError', + message: /Do not specify both wait-list and skip-list/, + }, + ); +}); diff --git a/src/schema.ts b/src/schema.ts index 3951fb60..377db835 100644 --- a/src/schema.ts +++ b/src/schema.ts @@ -5,16 +5,32 @@ const FilterCondition = z.object({ workflowFile: z.string().endsWith('.yml'), jobName: (z.string().min(1)).optional(), }); - const SkipFilterCondition = FilterCondition.readonly(); -export const SkipFilterConditions = z.array(SkipFilterCondition).readonly(); -export type SkipFilterConditions = z.infer; - const WaitFilterCondition = FilterCondition.extend( { optional: z.boolean().optional().default(false).readonly() }, ).readonly(); -export const WaitFilterConditions = z.array(WaitFilterCondition).readonly(); -export type WaitFilterConditions = z.infer; + +const retryMethods = z.enum(['exponential_backoff', 'equal_intervals']); +export type RetryMethod = z.infer; + +// - Do not specify default values with zod. That is an action.yml role +// - Do not include secrets here, for example githubToken. See https://github.com/colinhacks/zod/issues/1783 +export const Options = z.object({ + waitList: z.array(WaitFilterCondition).readonly(), + skipList: z.array(SkipFilterCondition).readonly(), + waitSecondsBeforeFirstPolling: z.number().min(0), + minIntervalSeconds: z.number().min(1), + retryMethod: retryMethods, + attemptLimits: z.number().min(1), + isEarlyExit: z.boolean(), + shouldSkipSameWorkflow: z.boolean(), + isDryRun: z.boolean(), +}).readonly().refine( + ({ waitList, skipList }) => !(waitList.length > 0 && skipList.length > 0), + { message: 'Do not specify both wait-list and skip-list', path: ['waitList', 'skipList'] }, +); + +export type Options = z.infer; export interface Trigger { owner: string; diff --git a/src/wait.ts b/src/wait.ts index 9fe6d055..b950a856 100644 --- a/src/wait.ts +++ b/src/wait.ts @@ -1,14 +1,9 @@ import { setTimeout } from 'timers/promises'; +import { RetryMethod } from './schema.ts'; // Just aliasing to avoid misusing setTimeout between ES method and timers/promises version. export const wait = setTimeout; -export const retryMethods = ['exponential_backoff', 'equal_intervals'] as const; -type retryMethod = typeof retryMethods[number]; -export const isRetryMethod = ( - method: string, -): method is retryMethod => (([...retryMethods] as string[]).includes(method)); - // Taken from MDN // The maximum is exclusive and the minimum is inclusive function getRandomInt(min: number, max: number) { @@ -45,7 +40,7 @@ export function calcExponentialBackoffAndJitter( return ((minIntervalSeconds * (2 ** (attempts - 1))) * 1000) + jitterMilliseconds; } -export function getIdleMilliseconds(method: retryMethod, minIntervalSeconds: number, attempts: number): number { +export function getIdleMilliseconds(method: RetryMethod, minIntervalSeconds: number, attempts: number): number { switch (method) { case ('exponential_backoff'): return calcExponentialBackoffAndJitter(