From c8448eb1e435664b3731ea1ead2efa0d1bb83b5b Mon Sep 17 00:00:00 2001 From: Jarek Potiuk Date: Fri, 30 Oct 2020 19:32:07 +0100 Subject: [PATCH] Add feature of cancelling future duplicates (enabled by default) (#9) --- README.md | 27 ++++++++++-------- action.yml | 5 ++++ dist/index.js | 71 ++++++++++++++++++++++++++++------------------- src/main.ts | 76 ++++++++++++++++++++++++++++++++++----------------- 4 files changed, 115 insertions(+), 64 deletions(-) diff --git a/README.md b/README.md index 90c9a22..0a13160 100644 --- a/README.md +++ b/README.md @@ -106,17 +106,18 @@ and `schedule` events are no longer needed. ## Inputs -| Input | Required | Default | Comment | -|-------------------------|----------|--------------|------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| -| `token` | yes | | The github token passed from `${{ secrets.GITHUB_TOKEN }}` | -| `cancelMode` | no | `duplicates` | The mode to run cancel on. The available options are `duplicates`, `self`, `failedJobs`, `namedJobs` | -| `sourceRunId` | no | | Useful only in `workflow_run` triggered events. It should be set to the id of the workflow triggering the run `${{ github.event.workflow_run.id }}` in case cancel operation should cancel the source workflow. | -| `notifyPRCancel` | no | | Boolean. If set to true, it notifies the cancelled PRs with a comment containing reason why they are being cancelled. | -| `notifyPRCancelMessage` | no | | Optional cancel message to use instead of the default one when notifyPRCancel is true. It is only used in 'self' cancelling mode. | -| `notifyPRMessageStart` | no | | Only for workflow_run events triggered by the PRs. If not empty, it notifies those PRs with the message specified at the start of the workflow - adding the link to the triggered workflow_run. | -| `jobNameRegexps` | no | | An array of job name regexps. Only runs containing any job name matching any of of the regexp in this array are considered for cancelling in `failedJobs` and `namedJobs` cancel modes. | -| `skipEventTypes` | no | | Array of event names that should be skipped when cancelling (JSON-encoded string). This might be used in order to skip direct pushes or scheduled events. | -| `workflowFileName` | no | | Name of the workflow file. It can be used if you want to cancel a different workflow than yours. | +| Input | Required | Default | Comment | +|--------------------------|----------|--------------|------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| `token` | yes | | The github token passed from `${{ secrets.GITHUB_TOKEN }}` | +| `cancelMode` | no | `duplicates` | The mode to run cancel on. The available options are `duplicates`, `self`, `failedJobs`, `namedJobs` | +| `cancelFutureDuplicates` | no | true | In case of duplicate canceling, cancel also future duplicates leaving only the "freshest" running job and not all the future jobs. By default it is set to true. | +| `sourceRunId` | no | | Useful only in `workflow_run` triggered events. It should be set to the id of the workflow triggering the run `${{ github.event.workflow_run.id }}` in case cancel operation should cancel the source workflow. | +| `notifyPRCancel` | no | | Boolean. If set to true, it notifies the cancelled PRs with a comment containing reason why they are being cancelled. | +| `notifyPRCancelMessage` | no | | Optional cancel message to use instead of the default one when notifyPRCancel is true. It is only used in 'self' cancelling mode. | +| `notifyPRMessageStart` | no | | Only for workflow_run events triggered by the PRs. If not empty, it notifies those PRs with the message specified at the start of the workflow - adding the link to the triggered workflow_run. | +| `jobNameRegexps` | no | | An array of job name regexps. Only runs containing any job name matching any of of the regexp in this array are considered for cancelling in `failedJobs` and `namedJobs` cancel modes. | +| `skipEventTypes` | no | | Array of event names that should be skipped when cancelling (JSON-encoded string). This might be used in order to skip direct pushes or scheduled events. | +| `workflowFileName` | no | | Name of the workflow file. It can be used if you want to cancel a different workflow than yours. | The job cancel modes work as follows: @@ -205,6 +206,7 @@ jobs: name: "Cancel duplicate workflow runs" with: cancelMode: duplicates + cancelFutureDuplicates: true token: ${{ secrets.GITHUB_TOKEN }} sourceRunId: ${{ github.event.workflow_run.id }} notifyPRCancel: true @@ -266,6 +268,7 @@ jobs: name: "Cancel duplicate CI runs" with: cancelMode: duplicates + cancelFutureDuplicates: true token: ${{ secrets.GITHUB_TOKEN }} notifyPRCancel: true notifyPRMessageStart: | @@ -512,6 +515,7 @@ on: uses: potiuk/cancel-workflow-runs@v2 with: cancelMode: duplicates + cancelFutureDuplicates: true token: ${{ secrets.GITHUB_TOKEN }} workflowFileName: other_workflow.yml ``` @@ -553,6 +557,7 @@ jobs: name: "Cancel duplicate workflow runs" with: cancelMode: duplicates + cancelFutureDuplicates: true notifyPRCancel: true ``` diff --git a/action.yml b/action.yml index 103d4e4..aaec312 100644 --- a/action.yml +++ b/action.yml @@ -36,6 +36,11 @@ inputs: * `failedJobs` - cancels all runs that failed in jobs matching one of the regexps * `namedJobs` - cancels runs where names of some jobs match some of regexps required: false + cancelFutureDuplicates: + description: | + In case of duplicate canceling, cancel also future duplicates leaving only the "freshest" running + job and not all the future jobs. By default it is set to true. + required: false jobNameRegexps: description: | Array of job name regexps (JSON-encoded string). Used by `failedJobs` and `namedJobs` cancel modes diff --git a/dist/index.js b/dist/index.js index a347a2c..69bd164 100644 --- a/dist/index.js +++ b/dist/index.js @@ -1622,7 +1622,7 @@ function getWorkflowRuns(octokit, statusValues, cancelMode, createListRunQuery) return workflowRuns; }); } -function shouldBeCancelled(octokit, owner, repo, runItem, headRepo, cancelMode, sourceRunId, jobNamesRegexps, skipEventTypes) { +function shouldBeCancelled(octokit, owner, repo, runItem, headRepo, cancelMode, cancelFutureDuplicates, sourceRunId, jobNamesRegexps, skipEventTypes) { return __awaiter(this, void 0, void 0, function* () { if ('completed' === runItem.status.toString()) { core.info(`\nThe run ${runItem.id} is completed. Not cancelling it.\n`); @@ -1651,17 +1651,17 @@ function shouldBeCancelled(octokit, owner, repo, runItem, headRepo, cancelMode, else if (cancelMode === CancelMode.NAMED_JOBS) { // Cancel all jobs that have failed jobs (no matter when started) if (yield jobsMatchingNames(octokit, owner, repo, runItem.id, jobNamesRegexps, false)) { - core.info(`\nSome jobs have matching names in ${runItem.id} . Cancelling it.\n`); + core.info(`\nSome jobs have matching names in ${runItem.id} . Returning it.\n`); return true; } else { - core.info(`\nNone of the jobs match name in ${runItem.id}. Not cancelling it.\n`); + core.info(`\nNone of the jobs match name in ${runItem.id}. Returning it.\n`); return false; } } else if (cancelMode === CancelMode.SELF) { if (runItem.id === sourceRunId) { - core.info(`\nCancelling the "source" run: ${runItem.id}.\n`); + core.info(`\nReturning the "source" run: ${runItem.id}.\n`); return true; } else { @@ -1675,16 +1675,20 @@ function shouldBeCancelled(octokit, owner, repo, runItem, headRepo, cancelMode, `repo: ${runHeadRepo} (expected ${headRepo}). Not cancelling it\n`); return false; } - if (runItem.id === sourceRunId) { - core.info(`\nThis is my own run ${runItem.id}. I have self-preservation mechanism. Not cancelling myself!\n`); - return false; - } - else if (runItem.id > sourceRunId) { - core.info(`\nThe run ${runItem.id} is started later than mt own run ${sourceRunId}. Not cancelling it\n`); - return false; + if (cancelFutureDuplicates) { + core.info(`\nCancel Future Duplicates: Returning run id that might be duplicate or my own run: ${runItem.id}.\n`); + return true; } else { - core.info(`\nCancelling duplicate of my own run: ${runItem.id}.\n`); + if (runItem.id === sourceRunId) { + core.info(`\nThis is my own run ${runItem.id}. Not returning myself!\n`); + return false; + } + else if (runItem.id > sourceRunId) { + core.info(`\nThe run ${runItem.id} is started later than my own run ${sourceRunId}. Not returning it\n`); + return false; + } + core.info(`\nFound duplicate of my own run: ${runItem.id}.\n`); return true; } } @@ -1710,7 +1714,7 @@ function cancelRun(octokit, owner, repo, runId) { } }); } -function findAndCancelRuns(octokit, selfRunId, sourceWorkflowId, sourceRunId, owner, repo, headRepo, headBranch, sourceEventName, cancelMode, notifyPRCancel, notifyPRMessageStart, jobNameRegexps, skipEventTypes, reason) { +function findAndCancelRuns(octokit, selfRunId, sourceWorkflowId, sourceRunId, owner, repo, headRepo, headBranch, sourceEventName, cancelMode, cancelFutureDuplicates, notifyPRCancel, notifyPRMessageStart, jobNameRegexps, skipEventTypes, reason) { return __awaiter(this, void 0, void 0, function* () { const statusValues = ['queued', 'in_progress']; const workflowRuns = yield getWorkflowRuns(octokit, statusValues, cancelMode, function (status) { @@ -1735,29 +1739,39 @@ function findAndCancelRuns(octokit, selfRunId, sourceWorkflowId, sourceRunId, ow throw Error(`\nWrong cancel mode ${cancelMode}! This should never happen.\n`); } }); - const idsToCancel = []; + const workflowsToCancel = []; const pullRequestToNotify = []; for (const [key, runItem] of workflowRuns) { - core.info(`\nChecking run number: ${key}, RunId: ${runItem.id}, Url: ${runItem.url}. Status ${runItem.status}\n`); - if (yield shouldBeCancelled(octokit, owner, repo, runItem, headRepo, cancelMode, sourceRunId, jobNameRegexps, skipEventTypes)) { + core.info(`\nChecking run number: ${key}, RunId: ${runItem.id}, Url: ${runItem.url}. Status ${runItem.status},` + + ` Created at ${runItem.created_at}\n`); + if (yield shouldBeCancelled(octokit, owner, repo, runItem, headRepo, cancelMode, cancelFutureDuplicates, sourceRunId, jobNameRegexps, skipEventTypes)) { if (notifyPRCancel && runItem.event === 'pull_request') { const pullRequest = yield findPullRequest(octokit, owner, repo, runItem.head_repository.owner.login, runItem.head_branch, runItem.head_sha); if (pullRequest) { pullRequestToNotify.push(pullRequest.number); } } - idsToCancel.push(runItem.id); + workflowsToCancel.push([runItem.id, runItem.created_at]); } } - // Sort from smallest number - this way we always kill current one at the end (if we kill it at all) - const sortedIdsToCancel = idsToCancel.sort((id1, id2) => id1 - id2); - if (sortedIdsToCancel.length > 0) { + // Sort from most recent date - this way we always kill current one at the end (if we kill it at all) + const sortedRunTuplesToCancel = workflowsToCancel.sort((runTuple1, runTuple2) => runTuple2[1].localeCompare(runTuple1[1])); + if (sortedRunTuplesToCancel.length > 0) { + if (cancelMode === CancelMode.DUPLICATES && cancelFutureDuplicates) { + core.info(`\nSkipping the first run (${sortedRunTuplesToCancel[0]}) of all the matching ` + + `duplicates - this one we are going to leave in peace!\n`); + sortedRunTuplesToCancel.shift(); + } + if (sortedRunTuplesToCancel.length === 0) { + core.info(`\nNo duplicates to cancel!\n`); + return sortedRunTuplesToCancel.map(runTuple => runTuple[0]); + } core.info('\n###### Cancelling runs starting from the oldest ##########\n' + - `\n Runs to cancel: ${sortedIdsToCancel.length}\n` + + `\n Runs to cancel: ${sortedRunTuplesToCancel.length}\n` + `\n PRs to notify: ${pullRequestToNotify.length}\n`); - for (const runId of sortedIdsToCancel) { - core.info(`\nCancelling run: ${runId}.\n`); - yield cancelRun(octokit, owner, repo, runId); + for (const runTuple of sortedRunTuplesToCancel) { + core.info(`\nCancelling run: ${runTuple}.\n`); + yield cancelRun(octokit, owner, repo, runTuple[0]); } for (const pullRequestNumber of pullRequestToNotify) { const selfWorkflowRunUrl = `https://github.com/${owner}/${repo}/actions/runs/${selfRunId}`; @@ -1768,7 +1782,7 @@ function findAndCancelRuns(octokit, selfRunId, sourceWorkflowId, sourceRunId, ow else { core.info('\n###### There are no runs to cancel! ##########\n'); } - return sortedIdsToCancel; + return sortedRunTuplesToCancel.map(runTuple => runTuple[0]); }); } function getRequiredEnv(key) { @@ -1837,7 +1851,7 @@ function getOrigin(octokit, runId, owner, repo) { ]; }); } -function performCancelJob(octokit, selfRunId, sourceWorkflowId, sourceRunId, owner, repo, headRepo, headBranch, sourceEventName, cancelMode, notifyPRCancel, notifyPRCancelMessage, notifyPRMessageStart, jobNameRegexps, skipEventTypes) { +function performCancelJob(octokit, selfRunId, sourceWorkflowId, sourceRunId, owner, repo, headRepo, headBranch, sourceEventName, cancelMode, notifyPRCancel, notifyPRCancelMessage, notifyPRMessageStart, jobNameRegexps, skipEventTypes, cancelFutureDuplicates) { return __awaiter(this, void 0, void 0, function* () { core.info('\n###################################################################################\n'); core.info(`All parameters: owner: ${owner}, repo: ${repo}, run id: ${sourceRunId}, ` + @@ -1867,7 +1881,7 @@ function performCancelJob(octokit, selfRunId, sourceWorkflowId, sourceRunId, own throw Error(`Wrong cancel mode ${cancelMode}! This should never happen.`); } core.info('\n###################################################################################\n'); - return yield findAndCancelRuns(octokit, selfRunId, sourceWorkflowId, sourceRunId, owner, repo, headRepo, headBranch, sourceEventName, cancelMode, notifyPRCancel, notifyPRMessageStart, jobNameRegexps, skipEventTypes, reason); + return yield findAndCancelRuns(octokit, selfRunId, sourceWorkflowId, sourceRunId, owner, repo, headRepo, headBranch, sourceEventName, cancelMode, cancelFutureDuplicates, notifyPRCancel, notifyPRMessageStart, jobNameRegexps, skipEventTypes, reason); }); } function verboseOutput(name, value) { @@ -1887,6 +1901,7 @@ function run() { const notifyPRMessageStart = core.getInput('notifyPRMessageStart'); const sourceRunId = parseInt(core.getInput('sourceRunId')) || selfRunId; const jobNameRegexpsString = core.getInput('jobNameRegexps'); + const cancelFutureDuplicates = (core.getInput('cancelFutureDuplicates') || 'true').toLowerCase() === 'true'; const jobNameRegexps = jobNameRegexpsString ? JSON.parse(jobNameRegexpsString) : []; @@ -1944,7 +1959,7 @@ function run() { body: `${notifyPRMessageStart} [The workflow run](${selfWorkflowRunUrl})` }); } - const cancelledRuns = yield performCancelJob(octokit, selfRunId, sourceWorkflowId, sourceRunId, owner, repo, headRepo, headBranch, sourceEventName, cancelMode, notifyPRCancel, notifyPRCancelMessage, notifyPRMessageStart, jobNameRegexps, skipEventTypes); + const cancelledRuns = yield performCancelJob(octokit, selfRunId, sourceWorkflowId, sourceRunId, owner, repo, headRepo, headBranch, sourceEventName, cancelMode, notifyPRCancel, notifyPRCancelMessage, notifyPRMessageStart, jobNameRegexps, skipEventTypes, cancelFutureDuplicates); verboseOutput('cancelledRuns', JSON.stringify(cancelledRuns)); }); } diff --git a/src/main.ts b/src/main.ts index 9f60f3d..71da8d9 100644 --- a/src/main.ts +++ b/src/main.ts @@ -203,6 +203,7 @@ async function shouldBeCancelled( runItem: rest.ActionsListWorkflowRunsResponseWorkflowRunsItem, headRepo: string, cancelMode: CancelMode, + cancelFutureDuplicates: boolean, sourceRunId: number, jobNamesRegexps: string[], skipEventTypes: string[] @@ -259,18 +260,18 @@ async function shouldBeCancelled( ) ) { core.info( - `\nSome jobs have matching names in ${runItem.id} . Cancelling it.\n` + `\nSome jobs have matching names in ${runItem.id} . Returning it.\n` ) return true } else { core.info( - `\nNone of the jobs match name in ${runItem.id}. Not cancelling it.\n` + `\nNone of the jobs match name in ${runItem.id}. Returning it.\n` ) return false } } else if (cancelMode === CancelMode.SELF) { if (runItem.id === sourceRunId) { - core.info(`\nCancelling the "source" run: ${runItem.id}.\n`) + core.info(`\nReturning the "source" run: ${runItem.id}.\n`) return true } else { return false @@ -284,18 +285,22 @@ async function shouldBeCancelled( ) return false } - if (runItem.id === sourceRunId) { + if (cancelFutureDuplicates) { core.info( - `\nThis is my own run ${runItem.id}. I have self-preservation mechanism. Not cancelling myself!\n` + `\nCancel Future Duplicates: Returning run id that might be duplicate or my own run: ${runItem.id}.\n` ) - return false - } else if (runItem.id > sourceRunId) { - core.info( - `\nThe run ${runItem.id} is started later than mt own run ${sourceRunId}. Not cancelling it\n` - ) - return false + return true } else { - core.info(`\nCancelling duplicate of my own run: ${runItem.id}.\n`) + if (runItem.id === sourceRunId) { + core.info(`\nThis is my own run ${runItem.id}. Not returning myself!\n`) + return false + } else if (runItem.id > sourceRunId) { + core.info( + `\nThe run ${runItem.id} is started later than my own run ${sourceRunId}. Not returning it\n` + ) + return false + } + core.info(`\nFound duplicate of my own run: ${runItem.id}.\n`) return true } } else { @@ -338,6 +343,7 @@ async function findAndCancelRuns( headBranch: string, sourceEventName: string, cancelMode: CancelMode, + cancelFutureDuplicates: boolean, notifyPRCancel: boolean, notifyPRMessageStart: string, jobNameRegexps: string[], @@ -400,11 +406,12 @@ async function findAndCancelRuns( } } ) - const idsToCancel: number[] = [] + const workflowsToCancel: [number, string][] = [] const pullRequestToNotify: number[] = [] for (const [key, runItem] of workflowRuns) { core.info( - `\nChecking run number: ${key}, RunId: ${runItem.id}, Url: ${runItem.url}. Status ${runItem.status}\n` + `\nChecking run number: ${key}, RunId: ${runItem.id}, Url: ${runItem.url}. Status ${runItem.status},` + + ` Created at ${runItem.created_at}\n` ) if ( await shouldBeCancelled( @@ -414,6 +421,7 @@ async function findAndCancelRuns( runItem, headRepo, cancelMode, + cancelFutureDuplicates, sourceRunId, jobNameRegexps, skipEventTypes @@ -432,20 +440,33 @@ async function findAndCancelRuns( pullRequestToNotify.push(pullRequest.number) } } - idsToCancel.push(runItem.id) + workflowsToCancel.push([runItem.id, runItem.created_at]) } } - // Sort from smallest number - this way we always kill current one at the end (if we kill it at all) - const sortedIdsToCancel = idsToCancel.sort((id1, id2) => id1 - id2) - if (sortedIdsToCancel.length > 0) { + // Sort from most recent date - this way we always kill current one at the end (if we kill it at all) + const sortedRunTuplesToCancel = workflowsToCancel.sort( + (runTuple1, runTuple2) => runTuple2[1].localeCompare(runTuple1[1]) + ) + if (sortedRunTuplesToCancel.length > 0) { + if (cancelMode === CancelMode.DUPLICATES && cancelFutureDuplicates) { + core.info( + `\nSkipping the first run (${sortedRunTuplesToCancel[0]}) of all the matching ` + + `duplicates - this one we are going to leave in peace!\n` + ) + sortedRunTuplesToCancel.shift() + } + if (sortedRunTuplesToCancel.length === 0) { + core.info(`\nNo duplicates to cancel!\n`) + return sortedRunTuplesToCancel.map(runTuple => runTuple[0]) + } core.info( '\n###### Cancelling runs starting from the oldest ##########\n' + - `\n Runs to cancel: ${sortedIdsToCancel.length}\n` + + `\n Runs to cancel: ${sortedRunTuplesToCancel.length}\n` + `\n PRs to notify: ${pullRequestToNotify.length}\n` ) - for (const runId of sortedIdsToCancel) { - core.info(`\nCancelling run: ${runId}.\n`) - await cancelRun(octokit, owner, repo, runId) + for (const runTuple of sortedRunTuplesToCancel) { + core.info(`\nCancelling run: ${runTuple}.\n`) + await cancelRun(octokit, owner, repo, runTuple[0]) } for (const pullRequestNumber of pullRequestToNotify) { const selfWorkflowRunUrl = `https://github.com/${owner}/${repo}/actions/runs/${selfRunId}` @@ -465,7 +486,7 @@ async function findAndCancelRuns( '\n###### There are no runs to cancel! ##########\n' ) } - return sortedIdsToCancel + return sortedRunTuplesToCancel.map(runTuple => runTuple[0]) } function getRequiredEnv(key: string): string { @@ -581,7 +602,8 @@ async function performCancelJob( notifyPRCancelMessage: string, notifyPRMessageStart: string, jobNameRegexps: string[], - skipEventTypes: string[] + skipEventTypes: string[], + cancelFutureDuplicates: boolean ): Promise { core.info( '\n###################################################################################\n' @@ -635,6 +657,7 @@ async function performCancelJob( headBranch, sourceEventName, cancelMode, + cancelFutureDuplicates, notifyPRCancel, notifyPRMessageStart, jobNameRegexps, @@ -662,6 +685,8 @@ async function run(): Promise { const notifyPRMessageStart = core.getInput('notifyPRMessageStart') const sourceRunId = parseInt(core.getInput('sourceRunId')) || selfRunId const jobNameRegexpsString = core.getInput('jobNameRegexps') + const cancelFutureDuplicates = + (core.getInput('cancelFutureDuplicates') || 'true').toLowerCase() === 'true' const jobNameRegexps = jobNameRegexpsString ? JSON.parse(jobNameRegexpsString) : [] @@ -761,7 +786,8 @@ async function run(): Promise { notifyPRCancelMessage, notifyPRMessageStart, jobNameRegexps, - skipEventTypes + skipEventTypes, + cancelFutureDuplicates ) verboseOutput('cancelledRuns', JSON.stringify(cancelledRuns))