From bc5e966da0a3120d5297b5c7642ef72e4b416001 Mon Sep 17 00:00:00 2001 From: Fe Date: Tue, 8 Oct 2024 18:18:23 +0200 Subject: [PATCH] feat: add option to skip summary --- .github/workflows/test.yml | 1 + action.yml | 4 + dist/index.js | 1085 ++++++++++++++++++------------------ src/main.ts | 5 +- 4 files changed, 553 insertions(+), 542 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index b53b65d..176753a 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -36,6 +36,7 @@ jobs: concurrent_skipping: 'never' skip_after_successful_duplicate: 'true' paths_ignore: '["**/README.md", "**/docs/**"]' + skip_summary: 'true' main_job: needs: pre_job diff --git a/action.yml b/action.yml index 2adc895..fe0cbfe 100644 --- a/action.yml +++ b/action.yml @@ -36,6 +36,10 @@ inputs: description: 'One of never, same_content, same_content_newer, outdated_runs, always' required: true default: 'never' + skip_summary: + description: 'If true, make the workflow logs shorter' + required: false + default: 'false' outputs: should_skip: description: 'Returns true if the current run should be skipped according to your configured rules' diff --git a/dist/index.js b/dist/index.js index b972596..1e5c673 100644 --- a/dist/index.js +++ b/dist/index.js @@ -5,547 +5,550 @@ /***/ (function(__unused_webpack_module, exports, __nccwpck_require__) { "use strict"; - -var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) { - if (k2 === undefined) k2 = k; - var desc = Object.getOwnPropertyDescriptor(m, k); - if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) { - desc = { enumerable: true, get: function() { return m[k]; } }; - } - Object.defineProperty(o, k2, desc); -}) : (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.prototype.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) { - function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } } - function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } } - function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); } - step((generator = generator.apply(thisArg, _arguments || [])).next()); - }); -}; -var __importDefault = (this && this.__importDefault) || function (mod) { - return (mod && mod.__esModule) ? mod : { "default": mod }; -}; -Object.defineProperty(exports, "__esModule", ({ value: true })); -const core = __importStar(__nccwpck_require__(2186)); -const github = __importStar(__nccwpck_require__(5438)); -const utils_1 = __nccwpck_require__(3030); -const plugin_retry_1 = __nccwpck_require__(6298); -const micromatch_1 = __importDefault(__nccwpck_require__(6228)); -const js_yaml_1 = __importDefault(__nccwpck_require__(1917)); -// Register 'retry' plugin with default values -const Octokit = utils_1.GitHub.plugin(plugin_retry_1.retry); -const workflowRunTriggerOptions = [ - 'pull_request', - 'push', - 'workflow_dispatch', - 'schedule', - 'release', - 'merge_group' -]; -const concurrentSkippingOptions = [ - 'always', - 'same_content', - 'same_content_newer', - 'outdated_runs', - 'never' -]; -class SkipDuplicateActions { - constructor(inputs, context) { - this.globOptions = { - dot: true // Match dotfiles. Otherwise dotfiles are ignored unless a "." is explicitly defined in the pattern. - }; - this.inputs = inputs; - this.context = context; - } - run() { - return __awaiter(this, void 0, void 0, function* () { - // Cancel outdated runs. - if (this.inputs.cancelOthers) { - yield this.cancelOutdatedRuns(); - } - // Abort early if current run has been triggered by an event that should never be skipped. - if (this.inputs.doNotSkip.includes(this.context.currentRun.event)) { - core.info(`Do not skip execution because the workflow was triggered with '${this.context.currentRun.event}'`); - yield exitSuccess({ - shouldSkip: false, - reason: 'do_not_skip' - }); - } - // Skip on successful duplicate run. - if (this.inputs.skipAfterSuccessfulDuplicates) { - const successfulDuplicateRun = this.findSuccessfulDuplicateRun(this.context.currentRun.treeHash); - if (successfulDuplicateRun) { - core.info(`Skip execution because the exact same files have been successfully checked in run ${successfulDuplicateRun.htmlUrl}`); - yield exitSuccess({ - shouldSkip: true, - reason: 'skip_after_successful_duplicate', - skippedBy: successfulDuplicateRun - }); - } - } - // Skip on concurrent runs. - if (this.inputs.concurrentSkipping !== 'never') { - const concurrentRun = this.detectConcurrentRuns(); - if (concurrentRun) { - yield exitSuccess({ - shouldSkip: true, - reason: 'concurrent_skipping', - skippedBy: concurrentRun - }); - } - } - // Skip on path matches. - if (this.inputs.paths.length >= 1 || - this.inputs.pathsIgnore.length >= 1 || - Object.keys(this.inputs.pathsFilter).length >= 1) { - const { changedFiles, pathsResult } = yield this.backtracePathSkipping(); - yield exitSuccess({ - shouldSkip: pathsResult.global.should_skip === 'unknown' - ? false - : pathsResult.global.should_skip, - reason: 'paths', - skippedBy: pathsResult.global.skipped_by, - pathsResult, - changedFiles - }); - } - // Do not skip otherwise. - core.info('Do not skip execution because we did not find a transferable run'); - yield exitSuccess({ - shouldSkip: false, - reason: 'no_transferable_run' - }); - }); - } - cancelOutdatedRuns() { - return __awaiter(this, void 0, void 0, function* () { - const cancelVictims = this.context.olderRuns.filter(run => { - // Only cancel runs which are not yet completed. - if (run.status === 'completed') { - return false; - } - // Only cancel runs from same branch and repo (ignore pull request runs from remote repositories) - // and not with same tree hash. - // See https://github.com/fkirc/skip-duplicate-actions/pull/177. - return (run.treeHash !== this.context.currentRun.treeHash && - run.branch === this.context.currentRun.branch && - run.repo === this.context.currentRun.repo); - }); - if (!cancelVictims.length) { - return core.info('Did not find other workflow runs to be cancelled'); - } - for (const victim of cancelVictims) { - try { - const res = yield this.context.octokit.rest.actions.cancelWorkflowRun(Object.assign(Object.assign({}, this.context.repo), { run_id: victim.id })); - core.info(`Cancelled run ${victim.htmlUrl} with response code ${res.status}`); - } - catch (error) { - if (error instanceof Error || typeof error === 'string') { - core.warning(error); - } - core.warning(`Failed to cancel ${victim.htmlUrl}`); - } - } - }); - } - findSuccessfulDuplicateRun(treeHash) { - return this.context.olderRuns.find(run => run.treeHash === treeHash && - run.status === 'completed' && - run.conclusion === 'success'); - } - detectConcurrentRuns() { - const concurrentRuns = this.context.allRuns.filter(run => run.status !== 'completed'); - if (!concurrentRuns.length) { - core.info('Did not find any concurrent workflow runs'); - return; - } - if (this.inputs.concurrentSkipping === 'always') { - core.info(`Skip execution because another instance of the same workflow is already running in ${concurrentRuns[0].htmlUrl}`); - return concurrentRuns[0]; - } - else if (this.inputs.concurrentSkipping === 'outdated_runs') { - const newerRun = concurrentRuns.find(run => new Date(run.createdAt).getTime() > - new Date(this.context.currentRun.createdAt).getTime()); - if (newerRun) { - core.info(`Skip execution because a newer instance of the same workflow is running in ${newerRun.htmlUrl}`); - return newerRun; - } - } - else if (this.inputs.concurrentSkipping === 'same_content') { - const concurrentDuplicate = concurrentRuns.find(run => run.treeHash === this.context.currentRun.treeHash); - if (concurrentDuplicate) { - core.info(`Skip execution because the exact same files are concurrently checked in run ${concurrentDuplicate.htmlUrl}`); - return concurrentDuplicate; - } - } - else if (this.inputs.concurrentSkipping === 'same_content_newer') { - const concurrentIsOlder = concurrentRuns.find(run => run.treeHash === this.context.currentRun.treeHash && - run.runNumber < this.context.currentRun.runNumber); - if (concurrentIsOlder) { - core.info(`Skip execution because the exact same files are concurrently checked in older run ${concurrentIsOlder.htmlUrl}`); - return concurrentIsOlder; - } - } - core.info(`Did not find any concurrent workflow runs that justify skipping`); - } - backtracePathSkipping() { - var _a, _b; - return __awaiter(this, void 0, void 0, function* () { - let commit; - let iterSha = this.context.currentRun.commitHash; - let distanceToHEAD = 0; - const allChangedFiles = []; - const pathsFilter = Object.assign(Object.assign({}, this.inputs.pathsFilter), { global: { - paths: this.inputs.paths, - paths_ignore: this.inputs.pathsIgnore, - backtracking: true - } }); - const pathsResult = {}; - for (const name of Object.keys(pathsFilter)) { - pathsResult[name] = { should_skip: 'unknown', backtrack_count: 0 }; - } - do { - commit = yield this.fetchCommitDetails(iterSha); - if (!commit) { - break; - } - iterSha = ((_a = commit.parents) === null || _a === void 0 ? void 0 : _a.length) ? (_b = commit.parents[0]) === null || _b === void 0 ? void 0 : _b.sha : null; - const changedFiles = commit.files - ? commit.files - .map(file => file.filename) - .filter(file => typeof file === 'string') - : []; - allChangedFiles.push({ - sha: commit.sha, - htmlUrl: commit.html_url, - changedFiles - }); - const successfulRun = (distanceToHEAD >= 1 && - this.findSuccessfulDuplicateRun(commit.commit.tree.sha)) || - undefined; - for (const [name, values] of Object.entries(pathsResult)) { - // Only process paths where status has not yet been determined. - if (values.should_skip !== 'unknown') - continue; - // Skip if paths were ignorable or skippable until now and there is a successful run for the current commit. - if (successfulRun) { - pathsResult[name].should_skip = true; - pathsResult[name].skipped_by = successfulRun; - pathsResult[name].backtrack_count = distanceToHEAD; - core.info(`Skip '${name}' because all changes since run ${successfulRun.htmlUrl} are in ignored or skipped paths`); - continue; - } - // Check if backtracking limit has been reached. - if ((pathsFilter[name].backtracking === false && distanceToHEAD === 1) || - pathsFilter[name].backtracking === distanceToHEAD) { - pathsResult[name].should_skip = false; - pathsResult[name].backtrack_count = distanceToHEAD; - core.info(`Stop backtracking for '${name}' because the defined limit has been reached`); - continue; - } - // Ignorable if all changed files match against ignored paths. - if (this.isCommitPathsIgnored(changedFiles, pathsFilter[name].paths_ignore)) { - core.info(`Commit ${commit.html_url} is path-ignored for '${name}': All of '${changedFiles}' match against patterns '${pathsFilter[name].paths_ignore}'`); - continue; - } - // Skippable if none of the changed files matches against paths. - if (pathsFilter[name].paths.length >= 1) { - const matches = this.getCommitPathsMatches(changedFiles, pathsFilter[name].paths); - if (matches.length === 0) { - core.info(`Commit ${commit.html_url} is path-skipped for '${name}': None of '${changedFiles}' matches against patterns '${pathsFilter[name].paths}'`); - continue; - } - else { - pathsResult[name].matched_files = matches; - } - } - // Not ignorable or skippable. - pathsResult[name].should_skip = false; - pathsResult[name].backtrack_count = distanceToHEAD; - core.info(`Stop backtracking for '${name}' at commit ${commit.html_url} because '${changedFiles}' are not skippable against paths '${pathsFilter[name].paths}' or paths_ignore '${pathsFilter[name].paths_ignore}'`); - } - // Should be never reached in practice; we expect that this loop aborts after 1-3 iterations. - if (distanceToHEAD++ >= 50) { - core.warning('Aborted commit-backtracing due to bad performance - Did you push an excessive number of ignored-path commits?'); - break; - } - } while (Object.keys(pathsResult).some(path => pathsResult[path].should_skip === 'unknown')); - return { pathsResult, changedFiles: allChangedFiles }; - }); - } - isCommitPathsIgnored(changedFiles, pathsIgnore) { - if (pathsIgnore.length === 0) { - return false; - } - const notIgnoredPaths = micromatch_1.default.not(changedFiles, pathsIgnore, this.globOptions); - return notIgnoredPaths.length === 0; - } - getCommitPathsMatches(changedFiles, paths) { - const matches = (0, micromatch_1.default)(changedFiles, paths, this.globOptions); - return matches; - } - fetchCommitDetails(sha) { - return __awaiter(this, void 0, void 0, function* () { - if (!sha) { - return null; - } - try { - return (yield this.context.octokit.rest.repos.getCommit(Object.assign(Object.assign({}, this.context.repo), { ref: sha }))).data; - } - catch (error) { - if (error instanceof Error || typeof error === 'string') { - core.warning(error); - } - core.warning(`Failed to retrieve commit ${sha}`); - return null; - } - }); - } -} -function main() { - var _a; - return __awaiter(this, void 0, void 0, function* () { - // Get and validate inputs. - const token = core.getInput('github_token', { required: true }); - const inputs = { - paths: getStringArrayInput('paths'), - pathsIgnore: getStringArrayInput('paths_ignore'), - pathsFilter: getPathsFilterInput('paths_filter'), - doNotSkip: getDoNotSkipInput('do_not_skip'), - concurrentSkipping: getConcurrentSkippingInput('concurrent_skipping'), - cancelOthers: core.getBooleanInput('cancel_others'), - skipAfterSuccessfulDuplicates: core.getBooleanInput('skip_after_successful_duplicate') - }; - const repo = github.context.repo; - const octokit = new Octokit((0, utils_1.getOctokitOptions)(token)); - // Get and parse the current workflow run. - let apiCurrentRun = null; - try { - const res = yield octokit.rest.actions.getWorkflowRun(Object.assign(Object.assign({}, repo), { run_id: github.context.runId })); - apiCurrentRun = res.data; - } - catch (error) { - core.warning(error); - yield exitSuccess({ - shouldSkip: false, - reason: 'no_transferable_run' - }); - } - const currentTreeHash = (_a = apiCurrentRun.head_commit) === null || _a === void 0 ? void 0 : _a.tree_id; - if (!currentTreeHash) { - exitFail(` - Could not find the tree hash of run ${apiCurrentRun.id} (Workflow ID: ${apiCurrentRun.workflow_id}, - Name: ${apiCurrentRun.name}, Head Branch: ${apiCurrentRun.head_branch}, Head SHA: ${apiCurrentRun.head_sha}). - This might be a run associated with a headless or removed commit. - `); - } - const currentRun = mapWorkflowRun(apiCurrentRun, currentTreeHash); - // Fetch list of runs for current workflow. - const { data: { workflow_runs: apiAllRuns } } = yield octokit.rest.actions.listWorkflowRuns(Object.assign(Object.assign({}, repo), { workflow_id: currentRun.workflowId, per_page: 100 })); - // List with all workflow runs. - const allRuns = []; - // List with older workflow runs only (used to prevent some nasty race conditions and edge cases). - const olderRuns = []; - // Check and map all runs. - for (const run of apiAllRuns) { - // Filter out current run and runs that lack 'head_commit' (most likely runs associated with a headless or removed commit). - // See https://github.com/fkirc/skip-duplicate-actions/pull/178. - if (run.id !== currentRun.id && run.head_commit) { - const mappedRun = mapWorkflowRun(run, run.head_commit.tree_id); - // Add to list of all runs. - allRuns.push(mappedRun); - // Check if run can be added to list of older runs. - if (new Date(mappedRun.createdAt).getTime() < - new Date(currentRun.createdAt).getTime()) { - olderRuns.push(mappedRun); - } - } - } - const skipDuplicateActions = new SkipDuplicateActions(inputs, { - repo, - octokit, - currentRun, - allRuns, - olderRuns - }); - yield skipDuplicateActions.run(); - }); -} -function mapWorkflowRun(run, treeHash) { - var _a, _b; - return { - id: run.id, - runNumber: run.run_number, - event: run.event, - treeHash, - commitHash: run.head_sha, - status: run.status, - conclusion: run.conclusion, - htmlUrl: run.html_url, - branch: run.head_branch, - // Wrong type: 'head_repository' can be null (probably when repo has been removed) - repo: (_b = (_a = run.head_repository) === null || _a === void 0 ? void 0 : _a.full_name) !== null && _b !== void 0 ? _b : null, - workflowId: run.workflow_id, - createdAt: run.created_at - }; -} -/** Set all outputs and exit the action. */ -function exitSuccess(args) { - var _a; - return __awaiter(this, void 0, void 0, function* () { - const summary = [ - '

Skip Duplicate Actions

', - '', - '', - '', - ``, - '', - '', - '', - ``, - '' - ]; - if (args.skippedBy) { - summary.push('', '', ``, ''); - } - if (args.pathsResult) { - summary.push('', '', ``, ''); - } - if (args.changedFiles) { - const changedFiles = args.changedFiles - .map(commit => `${commit.sha.substring(0, 7)}: - `) - .join(''); - summary.push('', '', ``, ''); - } - summary.push('
Should Skip${args.shouldSkip ? 'Yes' : 'No'} (${args.shouldSkip})
Reason${args.reason}
Skipped By${args.skippedBy.runNumber}
Paths Result
${JSON.stringify(args.pathsResult, null, 2)}
Changed Files${changedFiles}
'); - yield core.summary.addRaw(summary.join('')).write(); - core.setOutput('should_skip', args.shouldSkip); - core.setOutput('reason', args.reason); - core.setOutput('skipped_by', args.skippedBy || {}); - core.setOutput('paths_result', args.pathsResult || {}); - core.setOutput('changed_files', ((_a = args.changedFiles) === null || _a === void 0 ? void 0 : _a.map(commit => commit.changedFiles)) || []); - process.exit(0); - }); -} -/** Immediately terminate the action with failing exit code. */ -function exitFail(error) { - if (error instanceof Error || typeof error == 'string') { - core.error(error); - } - process.exit(1); -} -function getStringArrayInput(name) { - const rawInput = core.getInput(name); - if (!rawInput) { - return []; - } - try { - const array = JSON.parse(rawInput); - if (!Array.isArray(array)) { - exitFail(`Input '${rawInput}' is not a JSON-array`); - } - for (const element of array) { - if (typeof element !== 'string') { - exitFail(`Element '${element}' of input '${rawInput}' is not a string`); - } - } - return array; - } - catch (error) { - if (error instanceof Error || typeof error === 'string') { - core.error(error); - } - exitFail(`Input '${rawInput}' is not a valid JSON`); - } -} -function getDoNotSkipInput(name) { - const rawInput = core.getInput(name); - if (!rawInput) { - return []; - } - try { - const array = JSON.parse(rawInput); - if (!Array.isArray(array)) { - exitFail(`Input '${rawInput}' is not a JSON-array`); - } - for (const element of array) { - if (!workflowRunTriggerOptions.includes(element)) { - exitFail(`Elements in '${name}' must be one of ${workflowRunTriggerOptions - .map(option => `"${option}"`) - .join(', ')}`); - } - } - return array; - } - catch (error) { - if (error instanceof Error || typeof error === 'string') { - core.error(error); - } - exitFail(`Input '${rawInput}' is not a valid JSON`); - } -} -function getConcurrentSkippingInput(name) { - const rawInput = core.getInput(name, { required: true }); - if (rawInput.toLowerCase() === 'false') { - return 'never'; // Backwards-compat - } - else if (rawInput.toLowerCase() === 'true') { - return 'same_content'; // Backwards-compat - } - if (concurrentSkippingOptions.includes(rawInput)) { - return rawInput; - } - else { - exitFail(`'${name}' must be one of ${concurrentSkippingOptions - .map(option => `"${option}"`) - .join(', ')}`); - } -} -function getPathsFilterInput(name) { - const rawInput = core.getInput(name); - if (!rawInput) { - return {}; - } - try { - const input = js_yaml_1.default.load(rawInput); - // Assign default values to each entry - const pathsFilter = {}; - for (const [key, value] of Object.entries(input)) { - pathsFilter[key] = { - paths: value.paths || [], - paths_ignore: value.paths_ignore || [], - backtracking: value.backtracking == null ? true : value.backtracking - }; - } - return pathsFilter; - } - catch (error) { - if (error instanceof Error || typeof error === 'string') { - core.error(error); - } - exitFail(`Input '${rawInput}' is invalid`); - } -} -main(); + +var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) { + if (k2 === undefined) k2 = k; + var desc = Object.getOwnPropertyDescriptor(m, k); + if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) { + desc = { enumerable: true, get: function() { return m[k]; } }; + } + Object.defineProperty(o, k2, desc); +}) : (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.prototype.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) { + function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } } + function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } } + function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); } + step((generator = generator.apply(thisArg, _arguments || [])).next()); + }); +}; +var __importDefault = (this && this.__importDefault) || function (mod) { + return (mod && mod.__esModule) ? mod : { "default": mod }; +}; +Object.defineProperty(exports, "__esModule", ({ value: true })); +const core = __importStar(__nccwpck_require__(2186)); +const github = __importStar(__nccwpck_require__(5438)); +const utils_1 = __nccwpck_require__(3030); +const plugin_retry_1 = __nccwpck_require__(6298); +const micromatch_1 = __importDefault(__nccwpck_require__(6228)); +const js_yaml_1 = __importDefault(__nccwpck_require__(1917)); +// Register 'retry' plugin with default values +const Octokit = utils_1.GitHub.plugin(plugin_retry_1.retry); +const workflowRunTriggerOptions = [ + 'pull_request', + 'push', + 'workflow_dispatch', + 'schedule', + 'release', + 'merge_group' +]; +const concurrentSkippingOptions = [ + 'always', + 'same_content', + 'same_content_newer', + 'outdated_runs', + 'never' +]; +class SkipDuplicateActions { + constructor(inputs, context) { + this.globOptions = { + dot: true // Match dotfiles. Otherwise dotfiles are ignored unless a "." is explicitly defined in the pattern. + }; + this.inputs = inputs; + this.context = context; + } + run() { + return __awaiter(this, void 0, void 0, function* () { + // Cancel outdated runs. + if (this.inputs.cancelOthers) { + yield this.cancelOutdatedRuns(); + } + // Abort early if current run has been triggered by an event that should never be skipped. + if (this.inputs.doNotSkip.includes(this.context.currentRun.event)) { + core.info(`Do not skip execution because the workflow was triggered with '${this.context.currentRun.event}'`); + yield exitSuccess({ + shouldSkip: false, + reason: 'do_not_skip' + }); + } + // Skip on successful duplicate run. + if (this.inputs.skipAfterSuccessfulDuplicates) { + const successfulDuplicateRun = this.findSuccessfulDuplicateRun(this.context.currentRun.treeHash); + if (successfulDuplicateRun) { + core.info(`Skip execution because the exact same files have been successfully checked in run ${successfulDuplicateRun.htmlUrl}`); + yield exitSuccess({ + shouldSkip: true, + reason: 'skip_after_successful_duplicate', + skippedBy: successfulDuplicateRun + }); + } + } + // Skip on concurrent runs. + if (this.inputs.concurrentSkipping !== 'never') { + const concurrentRun = this.detectConcurrentRuns(); + if (concurrentRun) { + yield exitSuccess({ + shouldSkip: true, + reason: 'concurrent_skipping', + skippedBy: concurrentRun + }); + } + } + // Skip on path matches. + if (this.inputs.paths.length >= 1 || + this.inputs.pathsIgnore.length >= 1 || + Object.keys(this.inputs.pathsFilter).length >= 1) { + const { changedFiles, pathsResult } = yield this.backtracePathSkipping(); + yield exitSuccess({ + shouldSkip: pathsResult.global.should_skip === 'unknown' + ? false + : pathsResult.global.should_skip, + reason: 'paths', + skippedBy: pathsResult.global.skipped_by, + pathsResult, + changedFiles + }); + } + // Do not skip otherwise. + core.info('Do not skip execution because we did not find a transferable run'); + yield exitSuccess({ + shouldSkip: false, + reason: 'no_transferable_run' + }); + }); + } + cancelOutdatedRuns() { + return __awaiter(this, void 0, void 0, function* () { + const cancelVictims = this.context.olderRuns.filter(run => { + // Only cancel runs which are not yet completed. + if (run.status === 'completed') { + return false; + } + // Only cancel runs from same branch and repo (ignore pull request runs from remote repositories) + // and not with same tree hash. + // See https://github.com/fkirc/skip-duplicate-actions/pull/177. + return (run.treeHash !== this.context.currentRun.treeHash && + run.branch === this.context.currentRun.branch && + run.repo === this.context.currentRun.repo); + }); + if (!cancelVictims.length) { + return core.info('Did not find other workflow runs to be cancelled'); + } + for (const victim of cancelVictims) { + try { + const res = yield this.context.octokit.rest.actions.cancelWorkflowRun(Object.assign(Object.assign({}, this.context.repo), { run_id: victim.id })); + core.info(`Cancelled run ${victim.htmlUrl} with response code ${res.status}`); + } + catch (error) { + if (error instanceof Error || typeof error === 'string') { + core.warning(error); + } + core.warning(`Failed to cancel ${victim.htmlUrl}`); + } + } + }); + } + findSuccessfulDuplicateRun(treeHash) { + return this.context.olderRuns.find(run => run.treeHash === treeHash && + run.status === 'completed' && + run.conclusion === 'success'); + } + detectConcurrentRuns() { + const concurrentRuns = this.context.allRuns.filter(run => run.status !== 'completed'); + if (!concurrentRuns.length) { + core.info('Did not find any concurrent workflow runs'); + return; + } + if (this.inputs.concurrentSkipping === 'always') { + core.info(`Skip execution because another instance of the same workflow is already running in ${concurrentRuns[0].htmlUrl}`); + return concurrentRuns[0]; + } + else if (this.inputs.concurrentSkipping === 'outdated_runs') { + const newerRun = concurrentRuns.find(run => new Date(run.createdAt).getTime() > + new Date(this.context.currentRun.createdAt).getTime()); + if (newerRun) { + core.info(`Skip execution because a newer instance of the same workflow is running in ${newerRun.htmlUrl}`); + return newerRun; + } + } + else if (this.inputs.concurrentSkipping === 'same_content') { + const concurrentDuplicate = concurrentRuns.find(run => run.treeHash === this.context.currentRun.treeHash); + if (concurrentDuplicate) { + core.info(`Skip execution because the exact same files are concurrently checked in run ${concurrentDuplicate.htmlUrl}`); + return concurrentDuplicate; + } + } + else if (this.inputs.concurrentSkipping === 'same_content_newer') { + const concurrentIsOlder = concurrentRuns.find(run => run.treeHash === this.context.currentRun.treeHash && + run.runNumber < this.context.currentRun.runNumber); + if (concurrentIsOlder) { + core.info(`Skip execution because the exact same files are concurrently checked in older run ${concurrentIsOlder.htmlUrl}`); + return concurrentIsOlder; + } + } + core.info(`Did not find any concurrent workflow runs that justify skipping`); + } + backtracePathSkipping() { + var _a, _b; + return __awaiter(this, void 0, void 0, function* () { + let commit; + let iterSha = this.context.currentRun.commitHash; + let distanceToHEAD = 0; + const allChangedFiles = []; + const pathsFilter = Object.assign(Object.assign({}, this.inputs.pathsFilter), { global: { + paths: this.inputs.paths, + paths_ignore: this.inputs.pathsIgnore, + backtracking: true + } }); + const pathsResult = {}; + for (const name of Object.keys(pathsFilter)) { + pathsResult[name] = { should_skip: 'unknown', backtrack_count: 0 }; + } + do { + commit = yield this.fetchCommitDetails(iterSha); + if (!commit) { + break; + } + iterSha = ((_a = commit.parents) === null || _a === void 0 ? void 0 : _a.length) ? (_b = commit.parents[0]) === null || _b === void 0 ? void 0 : _b.sha : null; + const changedFiles = commit.files + ? commit.files + .map(file => file.filename) + .filter(file => typeof file === 'string') + : []; + allChangedFiles.push({ + sha: commit.sha, + htmlUrl: commit.html_url, + changedFiles + }); + const successfulRun = (distanceToHEAD >= 1 && + this.findSuccessfulDuplicateRun(commit.commit.tree.sha)) || + undefined; + for (const [name, values] of Object.entries(pathsResult)) { + // Only process paths where status has not yet been determined. + if (values.should_skip !== 'unknown') + continue; + // Skip if paths were ignorable or skippable until now and there is a successful run for the current commit. + if (successfulRun) { + pathsResult[name].should_skip = true; + pathsResult[name].skipped_by = successfulRun; + pathsResult[name].backtrack_count = distanceToHEAD; + core.info(`Skip '${name}' because all changes since run ${successfulRun.htmlUrl} are in ignored or skipped paths`); + continue; + } + // Check if backtracking limit has been reached. + if ((pathsFilter[name].backtracking === false && distanceToHEAD === 1) || + pathsFilter[name].backtracking === distanceToHEAD) { + pathsResult[name].should_skip = false; + pathsResult[name].backtrack_count = distanceToHEAD; + core.info(`Stop backtracking for '${name}' because the defined limit has been reached`); + continue; + } + // Ignorable if all changed files match against ignored paths. + if (this.isCommitPathsIgnored(changedFiles, pathsFilter[name].paths_ignore)) { + core.info(`Commit ${commit.html_url} is path-ignored for '${name}': All of '${changedFiles}' match against patterns '${pathsFilter[name].paths_ignore}'`); + continue; + } + // Skippable if none of the changed files matches against paths. + if (pathsFilter[name].paths.length >= 1) { + const matches = this.getCommitPathsMatches(changedFiles, pathsFilter[name].paths); + if (matches.length === 0) { + core.info(`Commit ${commit.html_url} is path-skipped for '${name}': None of '${changedFiles}' matches against patterns '${pathsFilter[name].paths}'`); + continue; + } + else { + pathsResult[name].matched_files = matches; + } + } + // Not ignorable or skippable. + pathsResult[name].should_skip = false; + pathsResult[name].backtrack_count = distanceToHEAD; + core.info(`Stop backtracking for '${name}' at commit ${commit.html_url} because '${changedFiles}' are not skippable against paths '${pathsFilter[name].paths}' or paths_ignore '${pathsFilter[name].paths_ignore}'`); + } + // Should be never reached in practice; we expect that this loop aborts after 1-3 iterations. + if (distanceToHEAD++ >= 50) { + core.warning('Aborted commit-backtracing due to bad performance - Did you push an excessive number of ignored-path commits?'); + break; + } + } while (Object.keys(pathsResult).some(path => pathsResult[path].should_skip === 'unknown')); + return { pathsResult, changedFiles: allChangedFiles }; + }); + } + isCommitPathsIgnored(changedFiles, pathsIgnore) { + if (pathsIgnore.length === 0) { + return false; + } + const notIgnoredPaths = micromatch_1.default.not(changedFiles, pathsIgnore, this.globOptions); + return notIgnoredPaths.length === 0; + } + getCommitPathsMatches(changedFiles, paths) { + const matches = (0, micromatch_1.default)(changedFiles, paths, this.globOptions); + return matches; + } + fetchCommitDetails(sha) { + return __awaiter(this, void 0, void 0, function* () { + if (!sha) { + return null; + } + try { + return (yield this.context.octokit.rest.repos.getCommit(Object.assign(Object.assign({}, this.context.repo), { ref: sha }))).data; + } + catch (error) { + if (error instanceof Error || typeof error === 'string') { + core.warning(error); + } + core.warning(`Failed to retrieve commit ${sha}`); + return null; + } + }); + } +} +function main() { + var _a; + return __awaiter(this, void 0, void 0, function* () { + // Get and validate inputs. + const token = core.getInput('github_token', { required: true }); + const inputs = { + paths: getStringArrayInput('paths'), + pathsIgnore: getStringArrayInput('paths_ignore'), + pathsFilter: getPathsFilterInput('paths_filter'), + doNotSkip: getDoNotSkipInput('do_not_skip'), + concurrentSkipping: getConcurrentSkippingInput('concurrent_skipping'), + cancelOthers: core.getBooleanInput('cancel_others'), + skipAfterSuccessfulDuplicates: core.getBooleanInput('skip_after_successful_duplicate') + }; + const repo = github.context.repo; + const octokit = new Octokit((0, utils_1.getOctokitOptions)(token)); + // Get and parse the current workflow run. + let apiCurrentRun = null; + try { + const res = yield octokit.rest.actions.getWorkflowRun(Object.assign(Object.assign({}, repo), { run_id: github.context.runId })); + apiCurrentRun = res.data; + } + catch (error) { + core.warning(error); + yield exitSuccess({ + shouldSkip: false, + reason: 'no_transferable_run' + }); + } + const currentTreeHash = (_a = apiCurrentRun.head_commit) === null || _a === void 0 ? void 0 : _a.tree_id; + if (!currentTreeHash) { + exitFail(` + Could not find the tree hash of run ${apiCurrentRun.id} (Workflow ID: ${apiCurrentRun.workflow_id}, + Name: ${apiCurrentRun.name}, Head Branch: ${apiCurrentRun.head_branch}, Head SHA: ${apiCurrentRun.head_sha}). + This might be a run associated with a headless or removed commit. + `); + } + const currentRun = mapWorkflowRun(apiCurrentRun, currentTreeHash); + // Fetch list of runs for current workflow. + const { data: { workflow_runs: apiAllRuns } } = yield octokit.rest.actions.listWorkflowRuns(Object.assign(Object.assign({}, repo), { workflow_id: currentRun.workflowId, per_page: 100 })); + // List with all workflow runs. + const allRuns = []; + // List with older workflow runs only (used to prevent some nasty race conditions and edge cases). + const olderRuns = []; + // Check and map all runs. + for (const run of apiAllRuns) { + // Filter out current run and runs that lack 'head_commit' (most likely runs associated with a headless or removed commit). + // See https://github.com/fkirc/skip-duplicate-actions/pull/178. + if (run.id !== currentRun.id && run.head_commit) { + const mappedRun = mapWorkflowRun(run, run.head_commit.tree_id); + // Add to list of all runs. + allRuns.push(mappedRun); + // Check if run can be added to list of older runs. + if (new Date(mappedRun.createdAt).getTime() < + new Date(currentRun.createdAt).getTime()) { + olderRuns.push(mappedRun); + } + } + } + const skipDuplicateActions = new SkipDuplicateActions(inputs, { + repo, + octokit, + currentRun, + allRuns, + olderRuns + }); + yield skipDuplicateActions.run(); + }); +} +function mapWorkflowRun(run, treeHash) { + var _a, _b; + return { + id: run.id, + runNumber: run.run_number, + event: run.event, + treeHash, + commitHash: run.head_sha, + status: run.status, + conclusion: run.conclusion, + htmlUrl: run.html_url, + branch: run.head_branch, + // Wrong type: 'head_repository' can be null (probably when repo has been removed) + repo: (_b = (_a = run.head_repository) === null || _a === void 0 ? void 0 : _a.full_name) !== null && _b !== void 0 ? _b : null, + workflowId: run.workflow_id, + createdAt: run.created_at + }; +} +/** Set all outputs and exit the action. */ +function exitSuccess(args) { + var _a; + return __awaiter(this, void 0, void 0, function* () { + const summary = [ + '

Skip Duplicate Actions

', + '', + '', + '', + ``, + '', + '', + '', + ``, + '' + ]; + if (args.skippedBy) { + summary.push('', '', ``, ''); + } + if (args.pathsResult) { + summary.push('', '', ``, ''); + } + if (args.changedFiles) { + const changedFiles = args.changedFiles + .map(commit => `${commit.sha.substring(0, 7)}: + `) + .join(''); + summary.push('', '', ``, ''); + } + summary.push('
Should Skip${args.shouldSkip ? 'Yes' : 'No'} (${args.shouldSkip})
Reason${args.reason}
Skipped By${args.skippedBy.runNumber}
Paths Result
${JSON.stringify(args.pathsResult, null, 2)}
Changed Files${changedFiles}
'); + const skipSummary = core.getBooleanInput("skip_summary"); + if (!skipSummary) { + yield core.summary.addRaw(summary.join('')).write(); + } + core.setOutput('should_skip', args.shouldSkip); + core.setOutput('reason', args.reason); + core.setOutput('skipped_by', args.skippedBy || {}); + core.setOutput('paths_result', args.pathsResult || {}); + core.setOutput('changed_files', ((_a = args.changedFiles) === null || _a === void 0 ? void 0 : _a.map(commit => commit.changedFiles)) || []); + process.exit(0); + }); +} +/** Immediately terminate the action with failing exit code. */ +function exitFail(error) { + if (error instanceof Error || typeof error == 'string') { + core.error(error); + } + process.exit(1); +} +function getStringArrayInput(name) { + const rawInput = core.getInput(name); + if (!rawInput) { + return []; + } + try { + const array = JSON.parse(rawInput); + if (!Array.isArray(array)) { + exitFail(`Input '${rawInput}' is not a JSON-array`); + } + for (const element of array) { + if (typeof element !== 'string') { + exitFail(`Element '${element}' of input '${rawInput}' is not a string`); + } + } + return array; + } + catch (error) { + if (error instanceof Error || typeof error === 'string') { + core.error(error); + } + exitFail(`Input '${rawInput}' is not a valid JSON`); + } +} +function getDoNotSkipInput(name) { + const rawInput = core.getInput(name); + if (!rawInput) { + return []; + } + try { + const array = JSON.parse(rawInput); + if (!Array.isArray(array)) { + exitFail(`Input '${rawInput}' is not a JSON-array`); + } + for (const element of array) { + if (!workflowRunTriggerOptions.includes(element)) { + exitFail(`Elements in '${name}' must be one of ${workflowRunTriggerOptions + .map(option => `"${option}"`) + .join(', ')}`); + } + } + return array; + } + catch (error) { + if (error instanceof Error || typeof error === 'string') { + core.error(error); + } + exitFail(`Input '${rawInput}' is not a valid JSON`); + } +} +function getConcurrentSkippingInput(name) { + const rawInput = core.getInput(name, { required: true }); + if (rawInput.toLowerCase() === 'false') { + return 'never'; // Backwards-compat + } + else if (rawInput.toLowerCase() === 'true') { + return 'same_content'; // Backwards-compat + } + if (concurrentSkippingOptions.includes(rawInput)) { + return rawInput; + } + else { + exitFail(`'${name}' must be one of ${concurrentSkippingOptions + .map(option => `"${option}"`) + .join(', ')}`); + } +} +function getPathsFilterInput(name) { + const rawInput = core.getInput(name); + if (!rawInput) { + return {}; + } + try { + const input = js_yaml_1.default.load(rawInput); + // Assign default values to each entry + const pathsFilter = {}; + for (const [key, value] of Object.entries(input)) { + pathsFilter[key] = { + paths: value.paths || [], + paths_ignore: value.paths_ignore || [], + backtracking: value.backtracking == null ? true : value.backtracking + }; + } + return pathsFilter; + } + catch (error) { + if (error instanceof Error || typeof error === 'string') { + core.error(error); + } + exitFail(`Input '${rawInput}' is invalid`); + } +} +main(); /***/ }), diff --git a/src/main.ts b/src/main.ts index 1d4509b..0f7bb6b 100644 --- a/src/main.ts +++ b/src/main.ts @@ -611,7 +611,10 @@ async function exitSuccess(args: { ) } summary.push('') - await core.summary.addRaw(summary.join('')).write() + const skipSummary = core.getBooleanInput("skip_summary") + if (!skipSummary) { + await core.summary.addRaw(summary.join('')).write() + } core.setOutput('should_skip', args.shouldSkip) core.setOutput('reason', args.reason)