-
-
Notifications
You must be signed in to change notification settings - Fork 42
/
Copy pathindex.ts
406 lines (375 loc) · 13.6 KB
/
index.ts
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
import * as core from '@actions/core'
import * as github from '@actions/github'
import {
ActionsGetWorkflowRunResponseData,
ActionsListWorkflowRunsResponseData,
ReposGetCommitResponseData
} from '@octokit/types'
const micromatch = require('micromatch');
type WorkflowRunStatus = 'queued' | 'in_progress' | 'completed'
type WorkflowRunConclusion = 'success' | 'failure' | 'neutral' | 'cancelled' | 'skipped' | 'timed_out'
const concurrentSkippingMap = {
"always": null,
"same_content": null,
"same_content_newer": null,
"outdated_runs": null,
"never": null,
}
function getConcurrentSkippingOptions(): string[] {
return Object.keys(concurrentSkippingMap);
}
type ConcurrentSkippingOption = keyof typeof concurrentSkippingMap;
interface WorkflowRun {
event: WRunTrigger;
treeHash: string;
commitHash: string;
status: WorkflowRunStatus;
conclusion: WorkflowRunConclusion | null;
html_url: string;
branch: string | null;
runId: number;
workflowId: number;
createdAt: string;
runNumber: number;
}
type WRunTrigger = "pull_request" | "push" | "workflow_dispatch" | "schedule";
interface WRunContext {
repoOwner: string;
repoName: string;
currentRun: WorkflowRun;
olderRuns: WorkflowRun[];
allRuns: WorkflowRun[];
octokit: any;
pathsIgnore: string[];
paths: string[];
doNotSkip: WRunTrigger[];
concurrentSkipping: ConcurrentSkippingOption;
}
function parseWorkflowRun(run: ActionsGetWorkflowRunResponseData): WorkflowRun {
const treeHash = run.head_commit?.tree_id;
if (!treeHash) {
logFatal(`Could not find the tree hash of run ${run}`);
}
const workflowId = run.workflow_id;
if (!workflowId) {
logFatal(`Could not find the workflow id of run ${run}`);
}
return {
event: run.event as WRunTrigger,
treeHash,
commitHash: run.head_sha,
status: run.status as WorkflowRunStatus,
conclusion: run.conclusion as WorkflowRunConclusion ?? null,
html_url: run.html_url,
branch: run.head_branch ?? null,
runId: run.id,
workflowId,
createdAt: run.created_at,
runNumber: run.run_number,
}
}
function parseAllRuns(response: ActionsListWorkflowRunsResponseData): WorkflowRun[] {
return response.workflow_runs.map((run) => parseWorkflowRun(run));
}
function parseOlderRuns(response: ActionsListWorkflowRunsResponseData, currentRun: WorkflowRun): WorkflowRun[] {
const olderRuns = response.workflow_runs.filter((run) => {
// Only consider older workflow-runs to prevent some nasty race conditions and edge cases.
return new Date(run.created_at).getTime() < new Date(currentRun.createdAt).getTime();
});
return olderRuns.map((run) => parseWorkflowRun(run));
}
async function main() {
const token = core.getInput('github_token', { required: true });
if (!token) {
logFatal("Did not find github_token");
}
const repo = github.context.repo;
const repoOwner = repo?.owner;
if (!repoOwner) {
logFatal("Did not find the repo owner");
}
const repoName = repo?.repo;
if (!repoName) {
logFatal("Did not find the repo name");
}
const runId = github.context.runId;
if (!runId) {
logFatal("Did not find runId");
}
let context: WRunContext;
try {
const octokit = github.getOctokit(token);
const { data: current_run } = await octokit.actions.getWorkflowRun({
owner: repoOwner,
repo: repoName,
run_id: runId,
});
const currentRun = parseWorkflowRun(current_run);
const { data } = await octokit.actions.listWorkflowRuns({
owner: repoOwner,
repo: repoName,
workflow_id: currentRun.workflowId,
per_page: 100,
});
context = {
repoOwner,
repoName,
currentRun,
olderRuns: parseOlderRuns(data, currentRun),
allRuns: parseAllRuns(data),
octokit,
pathsIgnore: getStringArrayInput("paths_ignore"),
paths: getStringArrayInput("paths"),
doNotSkip: getStringArrayInput("do_not_skip") as WRunTrigger[],
concurrentSkipping: getConcurrentSkippingInput("concurrent_skipping"),
};
} catch (e) {
core.warning(e);
core.warning(`Failed to fetch the required workflow information`);
exitSuccess({shouldSkip: false});
}
const cancelOthers = getBooleanInput('cancel_others', false);
if (cancelOthers) {
await cancelOutdatedRuns(context);
}
if (context.doNotSkip.includes(context.currentRun.event)) {
core.info(`Do not skip execution because the workflow was triggered with '${context.currentRun.event}'`);
exitSuccess({ shouldSkip: false });
}
const skipAfterSuccessfulDuplicates = getBooleanInput('skip_after_successful_duplicate', true);
if (skipAfterSuccessfulDuplicates) {
detectSuccessfulDuplicateRuns(context);
}
if (context.concurrentSkipping !== "never") {
detectConcurrentRuns(context);
}
if (context.paths.length >= 1 || context.pathsIgnore.length >= 1) {
if (skipAfterSuccessfulDuplicates) {
await backtracePathSkipping(context);
} else {
core.warning(`Ignore paths detection because 'skip_after_successful_duplicate' is set to false`);
}
}
core.info("Do not skip execution because we did not find a transferable run");
exitSuccess({ shouldSkip: false });
}
async function cancelOutdatedRuns(context: WRunContext) {
const currentRun = context.currentRun;
const cancelVictims = context.olderRuns.filter((run) => {
if (run.status === 'completed') {
return false;
}
return run.treeHash !== currentRun.treeHash && run.branch === currentRun.branch;
});
if (!cancelVictims.length) {
return core.info(`Did not find other workflow-runs to be cancelled`);
}
for (const victim of cancelVictims) {
await cancelWorkflowRun(victim, context)
}
}
async function cancelWorkflowRun(run: WorkflowRun, context: WRunContext) {
try {
const res = await context.octokit.actions.cancelWorkflowRun({
owner: context.repoOwner,
repo: context.repoName,
run_id: run.runId,
});
core.info(`Cancelled ${run.html_url} with response code ${res.status}`);
} catch (e) {
core.warning(e);
core.warning(`Failed to cancel ${run.html_url}`);
}
}
function detectSuccessfulDuplicateRuns(context: WRunContext) {
const duplicateRuns = context.olderRuns.filter((run) => run.treeHash === context.currentRun.treeHash);
const successfulDuplicate = duplicateRuns.find((run) => {
return run.status === 'completed' && run.conclusion === 'success';
});
if (successfulDuplicate) {
core.info(`Skip execution because the exact same files have been successfully checked in ${successfulDuplicate.html_url}`);
exitSuccess({ shouldSkip: true });
}
}
function detectConcurrentRuns(context: WRunContext) {
const concurrentRuns: WorkflowRun[] = context.allRuns.filter((run) => {
if (run.status === 'completed') {
return false;
}
if (run.runId === context.currentRun.runId) {
return false;
}
return true;
});
if (!concurrentRuns.length) {
core.info(`Did not find any concurrent workflow-runs`);
return;
}
if (context.concurrentSkipping === "always") {
core.info(`Skip execution because another instance of the same workflow is already running in ${concurrentRuns[0].html_url}`);
exitSuccess({ shouldSkip: true });
} else if (context.concurrentSkipping === "outdated_runs") {
const newerRun = concurrentRuns.find((run) => new Date(run.createdAt).getTime() > new Date(context.currentRun.createdAt).getTime());
if (newerRun) {
core.info(`Skip execution because a newer instance of the same workflow is running in ${newerRun.html_url}`);
exitSuccess({ shouldSkip: true });
}
} else if (context.concurrentSkipping === "same_content" || context.concurrentSkipping === "same_content_newer") {
const concurrentDuplicate = concurrentRuns.find((run) => run.treeHash === context.currentRun.treeHash);
if (concurrentDuplicate) {
if (context.concurrentSkipping === "same_content") {
core.info(`Skip execution because the exact same files are concurrently checked in ${concurrentDuplicate.html_url}`);
exitSuccess({ shouldSkip: true });
} else if (context.concurrentSkipping === "same_content_newer") {
const concurrentIsOlder = concurrentRuns.find((run) => run.runNumber < context.currentRun.runNumber);
if (concurrentIsOlder) {
core.info(`Skip execution because the exact same files are concurrently checked in older ${concurrentDuplicate.html_url}`);
exitSuccess({ shouldSkip: true });
}
}
}
}
core.info(`Did not find any skippable concurrent workflow-runs`);
}
async function backtracePathSkipping(context: WRunContext) {
let commit: ReposGetCommitResponseData | null;
let iterSha: string | null = context.currentRun.commitHash;
let distanceToHEAD = 0;
do {
commit = await fetchCommitDetails(iterSha, context);
if (!commit) {
return;
}
iterSha = commit.parents?.length ? commit.parents[0]?.sha : null;
exitIfSuccessfulRunExists(commit, context);
if (distanceToHEAD++ >= 50) {
// Should be never reached in practice; we expect that this loop aborts after 1-3 iterations.
core.warning(`Aborted commit-backtracing due to bad performance - Did you push an excessive number of ignored-path-commits?`);
return;
}
} while (isCommitSkippable(commit, context));
}
function exitIfSuccessfulRunExists(commit: ReposGetCommitResponseData, context: WRunContext) {
const treeHash = commit.commit.tree.sha;
const matchingRuns = context.olderRuns.filter((run) => run.treeHash === treeHash);
const successfulRun = matchingRuns.find((run) => {
return run.status === 'completed' && run.conclusion === 'success';
});
if (successfulRun) {
core.info(`Skip execution because all changes since ${successfulRun.html_url} are in ignored or skipped paths`);
exitSuccess({ shouldSkip: true });
}
}
function isCommitSkippable(commit: ReposGetCommitResponseData, context: WRunContext): boolean {
const changedFiles = commit.files.map((f) => f.filename);
if (isCommitPathIgnored(commit, context)) {
core.info(`Commit ${commit.html_url} is path-ignored: All of '${changedFiles}' match against patterns '${context.pathsIgnore}'`);
return true;
}
if (isCommitPathSkipped(commit, context)) {
core.info(`Commit ${commit.html_url} is path-skipped: None of '${changedFiles}' matches against patterns '${context.paths}'`);
return true;
}
core.info(`Stop backtracking at commit ${commit.html_url} because '${changedFiles}' are not skippable against paths '${context.paths}' or paths_ignore '${context.pathsIgnore}'`);
return false;
}
const globOptions = {
dot: true, // Match dotfiles. Otherwise dotfiles are ignored unless a . is explicitly defined in the pattern.
};
function isCommitPathIgnored(commit: ReposGetCommitResponseData, context: WRunContext): boolean {
if (!context.pathsIgnore.length) {
return false;
}
// Skip if all changed files match against pathsIgnore.
const changedFiles = commit.files.map((f) => f.filename);
const notIgnoredPaths = micromatch.not(changedFiles, context.pathsIgnore, globOptions);
return notIgnoredPaths.length === 0;
}
function isCommitPathSkipped(commit: ReposGetCommitResponseData, context: WRunContext): boolean {
if (!context.paths.length) {
return false;
}
// Skip if none of the changed files matches against context.paths.
const changedFiles = commit.files.map((f) => f.filename);
const matchExists = micromatch.some(changedFiles, context.paths, globOptions);
return !matchExists;
}
async function fetchCommitDetails(sha: string | null, context: WRunContext): Promise<ReposGetCommitResponseData | null> {
if (!sha) {
return null;
}
try {
const res = await context.octokit.repos.getCommit({
owner: context.repoOwner,
repo: context.repoName,
ref: sha,
});
//core.info(`Fetched ${res} with response code ${res.status}`);
return res.data;
} catch (e) {
core.warning(e);
core.warning(`Failed to retrieve commit ${sha}`);
return null;
}
}
function exitSuccess(args: { shouldSkip: boolean }): never {
core.setOutput("should_skip", args.shouldSkip);
return process.exit(0) as never;
}
function formatCliOptions(options: string[]): string {
return `${options.map((o) => `"${o}"`).join(", ")}`;
}
function getConcurrentSkippingInput(name: string): ConcurrentSkippingOption {
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
}
const options = getConcurrentSkippingOptions();
if (options.includes(rawInput)) {
return rawInput as ConcurrentSkippingOption;
} else {
logFatal(`'${name}' must be one of ${formatCliOptions(options)}`);
}
}
function getBooleanInput(name: string, defaultValue: boolean): boolean {
const rawInput = core.getInput(name, { required: false });
if (!rawInput) {
return defaultValue;
}
if (defaultValue) {
return rawInput.toLowerCase() !== 'false';
} else {
return rawInput.toLowerCase() === 'true';
}
}
function getStringArrayInput(name: string): string[] {
const rawInput = core.getInput(name, { required: false });
if (!rawInput) {
return [];
}
try {
const array = JSON.parse(rawInput);
if (!Array.isArray(array)) {
logFatal(`Input '${rawInput}' is not a JSON-array`);
}
array.forEach((e) => {
if (typeof e !== "string") {
logFatal(`Element '${e}' of input '${rawInput}' is not a string`);
}
});
return array;
} catch (e) {
core.error(e);
logFatal(`Input '${rawInput}' is not a valid JSON`);
}
}
function logFatal(msg: string): never {
core.setFailed(msg);
return process.exit(1) as never;
}
main().catch((e) => {
core.error(e);
logFatal(e.message);
});