blob: 01a8a9dd84d665a3718a2e6786c5cf451fd2f94d [file] [log] [blame]
import * as github from '@actions/github'
import * as core from '@actions/core'
import * as rest from '@octokit/rest'
import * as treemap from 'jstreemap'
const CANCELLABLE_RUNS = [
'push',
'pull_request',
'workflow_run',
'schedule',
'workflow_dispatch'
]
enum CancelMode {
DUPLICATES = 'duplicates',
SELF = 'self',
FAILED_JOBS = 'failedJobs',
NAMED_JOBS = 'namedJobs'
}
function createListRunsQueryOtherRuns(
octokit: github.GitHub,
owner: string,
repo: string,
status: string,
workflowId: number,
headBranch: string,
eventName: string
): rest.RequestOptions {
const request = {
owner,
repo,
// eslint-disable-next-line @typescript-eslint/camelcase
workflow_id: workflowId,
status,
branch: headBranch,
event: eventName
}
return octokit.actions.listWorkflowRuns.endpoint.merge(request)
}
function createListRunsQueryMyOwnRun(
octokit: github.GitHub,
owner: string,
repo: string,
status: string,
workflowId: number,
runId: number
): rest.RequestOptions {
const request = {
owner,
repo,
// eslint-disable-next-line @typescript-eslint/camelcase
workflow_id: workflowId,
status,
// eslint-disable-next-line @typescript-eslint/camelcase
run_id: runId.toString()
}
return octokit.actions.listWorkflowRuns.endpoint.merge(request)
}
function createListRunsQueryAllRuns(
octokit: github.GitHub,
owner: string,
repo: string,
status: string,
workflowId: number
): rest.RequestOptions {
const request = {
owner,
repo,
// eslint-disable-next-line @typescript-eslint/camelcase
workflow_id: workflowId,
status
}
return octokit.actions.listWorkflowRuns.endpoint.merge(request)
}
function createJobsForWorkflowRunQuery(
octokit: github.GitHub,
owner: string,
repo: string,
runId: number
): rest.RequestOptions {
const request = {
owner,
repo,
// eslint-disable-next-line @typescript-eslint/camelcase
run_id: runId
}
return octokit.actions.listJobsForWorkflowRun.endpoint.merge(request)
}
function matchInArray(s: string, regexps: string[]): boolean {
for (const regexp of regexps) {
if (s.match(regexp)) {
return true
}
}
return false
}
async function jobsMatchingNames(
octokit: github.GitHub,
owner: string,
repo: string,
runId: number,
jobNameRegexps: string[],
checkIfFailed: boolean
): Promise<boolean> {
const listJobs = createJobsForWorkflowRunQuery(octokit, owner, repo, runId)
if (checkIfFailed) {
core.info(
`\nChecking if runId ${runId} has job names matching any of the ${jobNameRegexps} that failed\n`
)
} else {
core.info(
`\nChecking if runId ${runId} has job names matching any of the ${jobNameRegexps}\n`
)
}
for await (const item of octokit.paginate.iterator(listJobs)) {
for (const job of item.data.jobs) {
core.info(` The job name: ${job.name}, Conclusion: ${job.conclusion}`)
if (matchInArray(job.name, jobNameRegexps)) {
if (checkIfFailed) {
// Only fail the build if one of the matching jobs fail
if (job.conclusion === 'failure') {
core.info(
` The Job ${job.name} matches one of the ${jobNameRegexps} regexps and it failed. Cancelling run.`
)
return true
} else {
core.info(
` The Job ${job.name} matches one of the ${jobNameRegexps} regexps but it did not fail. So far, so good.`
)
}
} else {
// Fail the build if any of the job names match
core.info(
` The Job ${job.name} matches one of the ${jobNameRegexps} regexps. Cancelling run.`
)
return true
}
}
}
}
return false
}
async function getWorkflowId(
octokit: github.GitHub,
runId: number,
owner: string,
repo: string
): Promise<number> {
const reply = await octokit.actions.getWorkflowRun({
owner,
repo,
// eslint-disable-next-line @typescript-eslint/camelcase
run_id: runId
})
core.info(`The source run ${runId} is in ${reply.data.workflow_url} workflow`)
const workflowIdString = reply.data.workflow_url.split('/').pop() || ''
if (!(workflowIdString.length > 0)) {
throw new Error('Could not resolve workflow')
}
return parseInt(workflowIdString)
}
async function getWorkflowRuns(
octokit: github.GitHub,
statusValues: string[],
cancelMode: CancelMode,
createListRunQuery: CallableFunction
): Promise<
treemap.TreeMap<number, rest.ActionsListWorkflowRunsResponseWorkflowRunsItem>
> {
const workflowRuns = new treemap.TreeMap<
number,
rest.ActionsListWorkflowRunsResponseWorkflowRunsItem
>()
for (const status of statusValues) {
const listRuns = await createListRunQuery(status)
for await (const item of octokit.paginate.iterator(listRuns)) {
// There is some sort of bug where the pagination URLs point to a
// different endpoint URL which trips up the resulting representation
// In that case, fallback to the actual REST 'workflow_runs' property
const elements =
item.data.length === undefined ? item.data.workflow_runs : item.data
for (const element of elements) {
workflowRuns.set(element.run_number, element)
}
}
}
core.info(`\nFound runs: ${Array.from(workflowRuns).map(t => t[0])}\n`)
return workflowRuns
}
async function shouldBeCancelled(
octokit: github.GitHub,
owner: string,
repo: string,
runItem: rest.ActionsListWorkflowRunsResponseWorkflowRunsItem,
headRepo: string,
cancelMode: CancelMode,
sourceRunId: number,
jobNamesRegexps: string[]
): Promise<boolean> {
if ('completed' === runItem.status.toString()) {
core.info(`\nThe run ${runItem.id} is completed. Not cancelling it.\n`)
return false
}
if (!CANCELLABLE_RUNS.includes(runItem.event.toString())) {
core.info(
`\nThe run ${runItem.id} is (${runItem.event} event - not in ${CANCELLABLE_RUNS}). Not cancelling it.\n`
)
return false
}
if (cancelMode === CancelMode.FAILED_JOBS) {
// Cancel all jobs that have failed jobs (no matter when started)
if (
await jobsMatchingNames(
octokit,
owner,
repo,
runItem.id,
jobNamesRegexps,
true
)
) {
core.info(
`\nSome matching named jobs failed in ${runItem.id} . Cancelling it.\n`
)
return true
} else {
core.info(
`\nNone of the matching jobs failed in ${runItem.id}. Not cancelling it.\n`
)
return false
}
} else if (cancelMode === CancelMode.NAMED_JOBS) {
// Cancel all jobs that have failed jobs (no matter when started)
if (
await jobsMatchingNames(
octokit,
owner,
repo,
runItem.id,
jobNamesRegexps,
false
)
) {
core.info(
`\nSome jobs have matching names in ${runItem.id} . Cancelling it.\n`
)
return true
} else {
core.info(
`\nNone of the jobs match name in ${runItem.id}. Not cancelling it.\n`
)
return false
}
} else if (cancelMode === CancelMode.SELF) {
if (runItem.id === sourceRunId) {
core.info(`\nCancelling the "source" run: ${runItem.id}.\n`)
return true
} else {
return false
}
} else if (cancelMode === CancelMode.DUPLICATES) {
const runHeadRepo = runItem.head_repository.full_name
if (headRepo !== undefined && runHeadRepo !== headRepo) {
core.info(
`\nThe run ${runItem.id} is from a different ` +
`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
} else {
core.info(`\nCancelling duplicate of my own run: ${runItem.id}.\n`)
return true
}
} else {
throw Error(
`\nWrong cancel mode ${cancelMode}! This should never happen.\n`
)
}
}
async function cancelRun(
octokit: github.GitHub,
owner: string,
repo: string,
runId: number
): Promise<void> {
let reply
try {
reply = await octokit.actions.cancelWorkflowRun({
owner,
repo,
// eslint-disable-next-line @typescript-eslint/camelcase
run_id: runId
})
core.info(`\nThe run ${runId} cancelled, status = ${reply.status}\n`)
} catch (error) {
core.warning(
`\nCould not cancel run ${runId}: [${error.status}] ${error.message}\n`
)
}
}
async function findAndCancelRuns(
octokit: github.GitHub,
sourceWorkflowId: number,
sourceRunId: number,
owner: string,
repo: string,
headRepo: string,
headBranch: string,
sourceEventName: string,
cancelMode: CancelMode,
jobNameRegexps: string[]
): Promise<number[]> {
const statusValues = ['queued', 'in_progress']
const workflowRuns = await getWorkflowRuns(
octokit,
statusValues,
cancelMode,
function(status: string) {
if (cancelMode === CancelMode.SELF) {
core.info(
`\nFinding runs for my own run: Owner: ${owner}, Repo: ${repo}, ` +
`Workflow ID:${sourceWorkflowId}, Source Run id: ${sourceRunId}\n`
)
return createListRunsQueryMyOwnRun(
octokit,
owner,
repo,
status,
sourceWorkflowId,
sourceRunId
)
} else if (
cancelMode === CancelMode.FAILED_JOBS ||
cancelMode === CancelMode.NAMED_JOBS
) {
core.info(
`\nFinding runs for all runs: Owner: ${owner}, Repo: ${repo}, Status: ${status} ` +
`Workflow ID:${sourceWorkflowId}\n`
)
return createListRunsQueryAllRuns(
octokit,
owner,
repo,
status,
sourceWorkflowId
)
} else if (cancelMode === CancelMode.DUPLICATES) {
core.info(
`\nFinding duplicate runs: Owner: ${owner}, Repo: ${repo}, Status: ${status} ` +
`Workflow ID:${sourceWorkflowId}, Head Branch: ${headBranch},` +
`Event name: ${sourceEventName}\n`
)
return createListRunsQueryOtherRuns(
octokit,
owner,
repo,
status,
sourceWorkflowId,
headBranch,
sourceEventName
)
} else {
throw Error(
`\nWrong cancel mode ${cancelMode}! This should never happen.\n`
)
}
}
)
const idsToCancel: number[] = []
for (const [key, runItem] of workflowRuns) {
core.info(
`\nChecking run number: ${key}, RunId: ${runItem.id}, Url: ${runItem.url}. Status ${runItem.status}\n`
)
if (
await shouldBeCancelled(
octokit,
owner,
repo,
runItem,
headRepo,
cancelMode,
sourceRunId,
jobNameRegexps
)
) {
idsToCancel.push(runItem.id)
}
}
// 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) {
core.info(
'\n###### Cancelling runs starting from the oldest ##########\n' +
`\n Runs to cancel: ${sortedIdsToCancel.length}\n`
)
for (const runId of sortedIdsToCancel) {
core.info(`\nCancelling run: ${runId}.\n`)
await cancelRun(octokit, owner, repo, runId)
}
core.info(
'\n###### Finished cancelling runs ##########\n'
)
} else {
core.info(
'\n###### There are no runs to cancel! ##########\n'
)
}
return sortedIdsToCancel
}
function getRequiredEnv(key: string): string {
const value = process.env[key]
if (value === undefined) {
const message = `${key} was not defined.`
throw new Error(message)
}
return value
}
async function getOrigin(
octokit: github.GitHub,
runId: number,
owner: string,
repo: string
): Promise<[string, string, string, string]> {
const reply = await octokit.actions.getWorkflowRun({
owner,
repo,
// eslint-disable-next-line @typescript-eslint/camelcase
run_id: runId
})
const sourceRun = reply.data
core.info(
`Source workflow: Head repo: ${sourceRun.head_repository.full_name}, ` +
`Head branch: ${sourceRun.head_branch} ` +
`Event: ${sourceRun.event}, Head sha: ${sourceRun.head_sha}, url: ${sourceRun.url}`
)
return [
reply.data.head_repository.full_name,
reply.data.head_branch,
reply.data.event,
reply.data.head_sha
]
}
async function performCancelJob(
octokit: github.GitHub,
sourceWorkflowId: number,
sourceRunId: number,
owner: string,
repo: string,
headRepo: string,
headBranch: string,
sourceEventName: string,
cancelMode: CancelMode,
jobNameRegexps: string[]
): Promise<number[]> {
core.info(
'\n###################################################################################\n'
)
core.info(
`All parameters: owner: ${owner}, repo: ${repo}, run id: ${sourceRunId}, ` +
`head repo ${headRepo}, headBranch: ${headBranch}, ` +
`sourceEventName: ${sourceEventName}, cancelMode: ${cancelMode}, jobNames: ${jobNameRegexps}`
)
core.info(
'\n###################################################################################\n'
)
if (cancelMode === CancelMode.SELF) {
core.info(
`# Cancelling source run: ${sourceRunId} for workflow ${sourceWorkflowId}.`
)
} else if (cancelMode === CancelMode.FAILED_JOBS) {
core.info(
`# Cancel all runs for workflow ${sourceWorkflowId} where job names matching ${jobNameRegexps} failed.`
)
} else if (cancelMode === CancelMode.NAMED_JOBS) {
core.info(
`# Cancel all runs for workflow ${sourceWorkflowId} have job names matching ${jobNameRegexps}.`
)
} else if (cancelMode === CancelMode.DUPLICATES) {
core.info(
`# Cancel duplicate runs started before ${sourceRunId} for workflow ${sourceWorkflowId}.`
)
} else {
throw Error(`Wrong cancel mode ${cancelMode}! This should never happen.`)
}
core.info(
'\n###################################################################################\n'
)
return await findAndCancelRuns(
octokit,
sourceWorkflowId,
sourceRunId,
owner,
repo,
headRepo,
headBranch,
sourceEventName,
cancelMode,
jobNameRegexps
)
}
async function run(): Promise<void> {
const token = core.getInput('token', {required: true})
const octokit = new github.GitHub(token)
const selfRunId = parseInt(getRequiredEnv('GITHUB_RUN_ID'))
const repository = getRequiredEnv('GITHUB_REPOSITORY')
const eventName = getRequiredEnv('GITHUB_EVENT_NAME')
const cancelMode =
(core.getInput('cancelMode') as CancelMode) || CancelMode.DUPLICATES
const sourceRunId = parseInt(core.getInput('sourceRunId')) || selfRunId
const jobNameRegexpsString = core.getInput('jobNameRegexps')
const jobNameRegexps = jobNameRegexpsString
? JSON.parse(jobNameRegexpsString)
: []
const [owner, repo] = repository.split('/')
core.info(
`\nGetting workflow id for source run id: ${sourceRunId}, owner: ${owner}, repo: ${repo}\n`
)
const sourceWorkflowId = await getWorkflowId(
octokit,
sourceRunId,
owner,
repo
)
core.info(
`Repository: ${repository}, Owner: ${owner}, Repo: ${repo}, ` +
`Event name: ${eventName}, CancelMode: ${cancelMode}, ` +
`sourceWorkflowId: ${sourceWorkflowId}, sourceRunId: ${sourceRunId}, selfRunId: ${selfRunId}, ` +
`jobNames: ${jobNameRegexps}`
)
if (sourceRunId === selfRunId) {
core.info(`\nFinding runs for my own workflow ${sourceWorkflowId}\n`)
} else {
core.info(`\nFinding runs for source workflow ${sourceWorkflowId}\n`)
}
if (
jobNameRegexps.length > 0 &&
[CancelMode.DUPLICATES, CancelMode.SELF].includes(cancelMode)
) {
throw Error(`You cannot specify jobNames on ${cancelMode} cancelMode.`)
}
if (eventName === 'workflow_run' && sourceRunId === selfRunId) {
if (cancelMode === CancelMode.DUPLICATES)
throw Error(
`You cannot run "workflow_run" in ${cancelMode} cancelMode without "sourceId" input.` +
'It will likely not work as you intended - it will cancel runs which are not duplicates!' +
'See the docs for details.'
)
}
const [headRepo, headBranch, sourceEventName, headSha] = await getOrigin(
octokit,
sourceRunId,
owner,
repo
)
core.setOutput('sourceHeadRepo', headRepo)
core.setOutput('sourceHeadBranch', headBranch)
core.setOutput('sourceHeadSha', headSha)
core.setOutput('sourceEvent', sourceEventName)
const cancelledRuns = await performCancelJob(
octokit,
sourceWorkflowId,
sourceRunId,
owner,
repo,
headRepo,
headBranch,
sourceEventName,
cancelMode,
jobNameRegexps
)
core.setOutput('cancelledRuns', JSON.stringify(cancelledRuns))
}
run()
.then(() =>
core.info('\n############### Cancel complete ##################\n')
)
.catch(e => core.setFailed(e.message))