@zgeoff/chex 0.1.4 → 0.1.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@zgeoff/chex",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.6",
|
|
4
4
|
"description": "Run each shared docs and repo check as one subcommand, with the lefthook pre-push baseline that calls them",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"checks",
|
|
@@ -35,15 +35,15 @@
|
|
|
35
35
|
"typecheck": "tsrs"
|
|
36
36
|
},
|
|
37
37
|
"dependencies": {
|
|
38
|
-
"@zgeoff/std": "0.4.
|
|
38
|
+
"@zgeoff/std": "0.4.2",
|
|
39
39
|
"oxc-parser": "0.156.0",
|
|
40
40
|
"ts-pattern": "5.9.2"
|
|
41
41
|
},
|
|
42
42
|
"devDependencies": {
|
|
43
43
|
"@types/bun": "1.4.4",
|
|
44
|
-
"@zgeoff/rig": "0.1.
|
|
45
|
-
"@zgeoff/std": "0.4.
|
|
46
|
-
"@zgeoff/testing": "0.2.
|
|
44
|
+
"@zgeoff/rig": "0.1.4",
|
|
45
|
+
"@zgeoff/std": "0.4.2",
|
|
46
|
+
"@zgeoff/testing": "0.2.1",
|
|
47
47
|
"tiny-invariant": "1.3.3",
|
|
48
48
|
"tsrs": "0.18.0-ts7.1.0-dev.20260929",
|
|
49
49
|
"typescript": "7.0.2"
|
|
@@ -0,0 +1,244 @@
|
|
|
1
|
+
import { collectExpressionReferences } from './collect-expression-references.ts';
|
|
2
|
+
import { findMember } from './find-member.ts';
|
|
3
|
+
import { getTopLevelLine } from './get-top-level-line.ts';
|
|
4
|
+
import { pullRequestTriggers } from './pull-request-triggers.ts';
|
|
5
|
+
import type { Job, WorkflowContext, WorkflowFinding } from './workflow-context.ts';
|
|
6
|
+
|
|
7
|
+
export function collectConcurrencyFindings(context: WorkflowContext): WorkflowFinding[] {
|
|
8
|
+
const deployJobs = context.jobs.filter((job) => isDeployJob(job));
|
|
9
|
+
const workflowConcurrency = findMember(context.workflow, 'concurrency');
|
|
10
|
+
|
|
11
|
+
return [
|
|
12
|
+
...deployJobs.flatMap((job) => collectDeployFindings(context, job, workflowConcurrency)),
|
|
13
|
+
...(isChecksWorkflow(context)
|
|
14
|
+
? collectChecksFindings(context, deployJobs, workflowConcurrency)
|
|
15
|
+
: []),
|
|
16
|
+
];
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const deployJobPattern = /release|deploy|migrate/iu;
|
|
20
|
+
|
|
21
|
+
function isDeployJob(job: Job): boolean {
|
|
22
|
+
const name = findMember(job.definition, 'name');
|
|
23
|
+
|
|
24
|
+
return deployJobPattern.test(job.id) || (typeof name === 'string' && deployJobPattern.test(name));
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function collectDeployFindings(
|
|
28
|
+
context: WorkflowContext,
|
|
29
|
+
job: Job,
|
|
30
|
+
workflowConcurrency: unknown,
|
|
31
|
+
): WorkflowFinding[] {
|
|
32
|
+
const concurrency = findMember(job.definition, 'concurrency');
|
|
33
|
+
const isQueued = isQueueGroup(concurrency ?? workflowConcurrency);
|
|
34
|
+
|
|
35
|
+
return [
|
|
36
|
+
...(isQueued
|
|
37
|
+
? []
|
|
38
|
+
: [
|
|
39
|
+
{
|
|
40
|
+
path: context.path,
|
|
41
|
+
line: job.line,
|
|
42
|
+
message: `job ${job.id} releases or deploys, so it or its workflow sets a concurrency group with cancel-in-progress: false`,
|
|
43
|
+
},
|
|
44
|
+
]),
|
|
45
|
+
...(isCancelling(workflowConcurrency)
|
|
46
|
+
? [
|
|
47
|
+
{
|
|
48
|
+
path: context.path,
|
|
49
|
+
line: getTopLevelLine(context.lines, 'concurrency'),
|
|
50
|
+
message: `the workflow-level cancel-in-progress: true can cancel job ${job.id}, which releases or deploys; set each concurrency group per job`,
|
|
51
|
+
},
|
|
52
|
+
]
|
|
53
|
+
: []),
|
|
54
|
+
];
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function isQueueGroup(concurrency: unknown): boolean {
|
|
58
|
+
return (
|
|
59
|
+
typeof findMember(concurrency, 'group') === 'string' &&
|
|
60
|
+
findMember(concurrency, 'cancel-in-progress') === false
|
|
61
|
+
);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function isCancelling(concurrency: unknown): boolean {
|
|
65
|
+
return findMember(concurrency, 'cancel-in-progress') === true;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function isChecksWorkflow(context: WorkflowContext): boolean {
|
|
69
|
+
return context.triggers.has('pull_request') || context.triggers.has('push');
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function collectChecksFindings(
|
|
73
|
+
context: WorkflowContext,
|
|
74
|
+
deployJobs: readonly Job[],
|
|
75
|
+
workflowConcurrency: unknown,
|
|
76
|
+
): WorkflowFinding[] {
|
|
77
|
+
const otherJobs = context.jobs.filter((job) => !deployJobs.includes(job));
|
|
78
|
+
|
|
79
|
+
const isEveryJobCovered =
|
|
80
|
+
otherJobs.length > 0 &&
|
|
81
|
+
otherJobs.every((job) => isChecksGroup(context, findMember(job.definition, 'concurrency')));
|
|
82
|
+
|
|
83
|
+
const isCovered = isChecksGroup(context, workflowConcurrency) || isEveryJobCovered;
|
|
84
|
+
|
|
85
|
+
const workflowFindings =
|
|
86
|
+
deployJobs.length > 0 || isCovered
|
|
87
|
+
? []
|
|
88
|
+
: [
|
|
89
|
+
{
|
|
90
|
+
path: context.path,
|
|
91
|
+
line: getTopLevelLine(context.lines, 'on'),
|
|
92
|
+
message:
|
|
93
|
+
'a checks workflow, which runs on pull_request or push, sets concurrency with cancel-in-progress: true; a gate that runs only on a push to main may instead group by github.sha and never cancel',
|
|
94
|
+
},
|
|
95
|
+
];
|
|
96
|
+
|
|
97
|
+
const jobFindings =
|
|
98
|
+
deployJobs.length === 0
|
|
99
|
+
? []
|
|
100
|
+
: otherJobs
|
|
101
|
+
.filter((job) => !isChecksGroup(context, findMember(job.definition, 'concurrency')))
|
|
102
|
+
.map((job) => ({
|
|
103
|
+
path: context.path,
|
|
104
|
+
line: job.line,
|
|
105
|
+
message: `job ${job.id} runs in a checks workflow beside a release or deploy job, so it sets a job-level concurrency group with cancel-in-progress: true`,
|
|
106
|
+
}));
|
|
107
|
+
|
|
108
|
+
// A workflow-level group that never cancels queues the release or deploy jobs, and the job-level
|
|
109
|
+
// groups decide which checks cancel, so a per-run value there only keeps two runs apart.
|
|
110
|
+
const isDeployQueue = deployJobs.length > 0 && isQueueGroup(workflowConcurrency);
|
|
111
|
+
|
|
112
|
+
return [
|
|
113
|
+
...workflowFindings,
|
|
114
|
+
...jobFindings,
|
|
115
|
+
...(isDeployQueue
|
|
116
|
+
? []
|
|
117
|
+
: collectPerRunGroupFindings(
|
|
118
|
+
context,
|
|
119
|
+
workflowConcurrency,
|
|
120
|
+
getTopLevelLine(context.lines, 'concurrency'),
|
|
121
|
+
)),
|
|
122
|
+
...otherJobs.flatMap((job) =>
|
|
123
|
+
collectPerRunGroupFindings(context, findMember(job.definition, 'concurrency'), job.line),
|
|
124
|
+
),
|
|
125
|
+
];
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function isChecksGroup(context: WorkflowContext, concurrency: unknown): boolean {
|
|
129
|
+
return (
|
|
130
|
+
isCancelling(concurrency) ||
|
|
131
|
+
isGateGroup(context, concurrency) ||
|
|
132
|
+
isDispatchGroup(context, concurrency)
|
|
133
|
+
);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function isGateGroup(context: WorkflowContext, concurrency: unknown): boolean {
|
|
137
|
+
const group = findMember(concurrency, 'group');
|
|
138
|
+
const cancel = findMember(concurrency, 'cancel-in-progress');
|
|
139
|
+
|
|
140
|
+
return (
|
|
141
|
+
isMainGate(context) &&
|
|
142
|
+
typeof group === 'string' &&
|
|
143
|
+
collectExpressionReferences(group).includes('github.sha') &&
|
|
144
|
+
(cancel === undefined || cancel === false)
|
|
145
|
+
);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// A gate on main runs once for each commit, so a newer commit must not cancel the older run.
|
|
149
|
+
function isMainGate(context: WorkflowContext): boolean {
|
|
150
|
+
const push = findMember(findMember(context.workflow, 'on'), 'push');
|
|
151
|
+
const branches = [findMember(push, 'branches')].flat();
|
|
152
|
+
|
|
153
|
+
return (
|
|
154
|
+
!pullRequestTriggers.some((name) => context.triggers.has(name)) &&
|
|
155
|
+
branches.length > 0 &&
|
|
156
|
+
branches.every((branch) => branch === 'main')
|
|
157
|
+
);
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
// A dispatched run whose group is a run-unique value shares the group with no other run, so no run
|
|
161
|
+
// cancels it, and every other run still cancels.
|
|
162
|
+
function isDispatchGroup(context: WorkflowContext, concurrency: unknown): boolean {
|
|
163
|
+
const group = findMember(concurrency, 'group');
|
|
164
|
+
|
|
165
|
+
return (
|
|
166
|
+
typeof group === 'string' &&
|
|
167
|
+
findNonDispatchGroup(group) !== undefined &&
|
|
168
|
+
isCancellingOutsideDispatch(context, findMember(concurrency, 'cancel-in-progress'))
|
|
169
|
+
);
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
// GitHub reads `a && b || c` as `(a && b) || c`, so a dispatched run takes its group from b and
|
|
173
|
+
// every other run from c. A `||` inside b ends b early, so b no longer reads as one value.
|
|
174
|
+
const dispatchGroupPattern =
|
|
175
|
+
/^(?<prefix>[^$]*)\$\{\{\s*github\.event_name\s*==\s*'workflow_dispatch'\s*&&(?<dispatch>.+?)\|\|(?<other>.+)\}\}(?<suffix>[^$]*)$/su;
|
|
176
|
+
|
|
177
|
+
// Two dispatches at one commit share github.sha, and github.run_attempt starts at 1 in each run.
|
|
178
|
+
const dispatchRunValues = new Set(['github.run_id', 'github.run_number']);
|
|
179
|
+
|
|
180
|
+
// Returns the group a run other than a dispatched one reads, when a dispatched run's group is a
|
|
181
|
+
// lone run-unique value.
|
|
182
|
+
function findNonDispatchGroup(group: string): string | undefined {
|
|
183
|
+
const split = dispatchGroupPattern.exec(group)?.groups;
|
|
184
|
+
|
|
185
|
+
return split !== undefined && dispatchRunValues.has((split['dispatch'] ?? '').trim())
|
|
186
|
+
? `${split['prefix'] ?? ''}\${{ ${split['other'] ?? ''} }}${split['suffix'] ?? ''}`
|
|
187
|
+
: undefined;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
function isCancellingOutsideDispatch(context: WorkflowContext, cancel: unknown): boolean {
|
|
191
|
+
const events = [...context.triggers].filter((event) => event !== 'workflow_dispatch');
|
|
192
|
+
|
|
193
|
+
return (
|
|
194
|
+
cancel === true ||
|
|
195
|
+
(typeof cancel === 'string' && events.every((event) => isEventExpressionTrue(cancel, event)))
|
|
196
|
+
);
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
const wholeExpressionPattern = /^\s*\$\{\{(?<body>.*)\}\}\s*$/su;
|
|
200
|
+
const eventTestPattern = /^\s*github\.event_name\s*(?<operator>==|!=)\s*'(?<event>[^']*)'\s*$/u;
|
|
201
|
+
|
|
202
|
+
// Reads only github.event_name tests joined by ||; any other expression counts as false.
|
|
203
|
+
function isEventExpressionTrue(text: string, event: string): boolean {
|
|
204
|
+
const body = wholeExpressionPattern.exec(text)?.groups?.['body'];
|
|
205
|
+
const terms = body === undefined ? [] : body.split('||');
|
|
206
|
+
|
|
207
|
+
const tests = terms.flatMap((term) => {
|
|
208
|
+
const test = eventTestPattern.exec(term)?.groups;
|
|
209
|
+
|
|
210
|
+
return test === undefined ? [] : [test];
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
return (
|
|
214
|
+
tests.length > 0 &&
|
|
215
|
+
tests.length === terms.length &&
|
|
216
|
+
tests.some((test) => (test['operator'] === '==') === (test['event'] === event))
|
|
217
|
+
);
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
// A value that differs between two runs on the same branch gives each run its own group, which no
|
|
221
|
+
// later run cancels.
|
|
222
|
+
const perRunValues = ['github.sha', 'github.run_id', 'github.run_number', 'github.run_attempt'];
|
|
223
|
+
|
|
224
|
+
function collectPerRunGroupFindings(
|
|
225
|
+
context: WorkflowContext,
|
|
226
|
+
concurrency: unknown,
|
|
227
|
+
line: number,
|
|
228
|
+
): WorkflowFinding[] {
|
|
229
|
+
const group = findMember(concurrency, 'group');
|
|
230
|
+
const checked = typeof group === 'string' ? (findNonDispatchGroup(group) ?? group) : '';
|
|
231
|
+
const references = collectExpressionReferences(checked);
|
|
232
|
+
const allowed = isGateGroup(context, concurrency) ? 'github.sha' : undefined;
|
|
233
|
+
const perRun = perRunValues.find((value) => value !== allowed && references.includes(value));
|
|
234
|
+
|
|
235
|
+
return perRun === undefined
|
|
236
|
+
? []
|
|
237
|
+
: [
|
|
238
|
+
{
|
|
239
|
+
path: context.path,
|
|
240
|
+
line,
|
|
241
|
+
message: `the concurrency group holds ${perRun}, so no newer run cancels this one`,
|
|
242
|
+
},
|
|
243
|
+
];
|
|
244
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export const pullRequestTriggers = ['pull_request', 'pull_request_target'];
|
|
@@ -1,12 +1,18 @@
|
|
|
1
1
|
import { parseArgs } from 'node:util';
|
|
2
2
|
import { collectCommandNames } from './collect-command-names.ts';
|
|
3
|
-
import {
|
|
3
|
+
import { collectConcurrencyFindings } from './collect-concurrency-findings.ts';
|
|
4
4
|
import { findInstallCachePath } from './find-install-cache-path.ts';
|
|
5
|
+
import { findMember } from './find-member.ts';
|
|
6
|
+
import { getTopLevelLine } from './get-top-level-line.ts';
|
|
5
7
|
import { parseShellWords } from './parse-shell-words.ts';
|
|
8
|
+
import { pullRequestTriggers } from './pull-request-triggers.ts';
|
|
6
9
|
import type { ChecksProfile } from './read-checks-profile.ts';
|
|
7
10
|
import { readChecksProfile } from './read-checks-profile.ts';
|
|
8
11
|
import type { RunProgram } from './run-program.ts';
|
|
9
12
|
import { runProgram } from './run-program.ts';
|
|
13
|
+
import type { Job, WorkflowContext, WorkflowFinding } from './workflow-context.ts';
|
|
14
|
+
|
|
15
|
+
export type { WorkflowFinding } from './workflow-context.ts';
|
|
10
16
|
|
|
11
17
|
export interface WorkflowsCheckOptions {
|
|
12
18
|
readonly cwd: string;
|
|
@@ -21,14 +27,6 @@ export interface WorkflowsCheckOptions {
|
|
|
21
27
|
readonly runProgram: RunProgram;
|
|
22
28
|
}
|
|
23
29
|
|
|
24
|
-
export interface WorkflowFinding {
|
|
25
|
-
// Relative to the repo root.
|
|
26
|
-
readonly path: string;
|
|
27
|
-
|
|
28
|
-
readonly line: number;
|
|
29
|
-
readonly message: string;
|
|
30
|
-
}
|
|
31
|
-
|
|
32
30
|
// GitHub runs no workflow from a subfolder of .github/workflows.
|
|
33
31
|
const workflowPathPattern = /^(?:\.github\/workflows|sync\/workflow-callers)\/[^/]+\.ya?ml$/u;
|
|
34
32
|
|
|
@@ -57,21 +55,6 @@ function runGit(options: Readonly<WorkflowsCheckOptions>, args: readonly string[
|
|
|
57
55
|
return result.stdout;
|
|
58
56
|
}
|
|
59
57
|
|
|
60
|
-
interface Job {
|
|
61
|
-
readonly id: string;
|
|
62
|
-
readonly definition: unknown;
|
|
63
|
-
readonly line: number;
|
|
64
|
-
}
|
|
65
|
-
|
|
66
|
-
interface WorkflowContext {
|
|
67
|
-
readonly path: string;
|
|
68
|
-
readonly profile: ChecksProfile | undefined;
|
|
69
|
-
readonly workflow: unknown;
|
|
70
|
-
readonly lines: readonly string[];
|
|
71
|
-
readonly jobs: readonly Job[];
|
|
72
|
-
readonly triggers: ReadonlySet<string>;
|
|
73
|
-
}
|
|
74
|
-
|
|
75
58
|
function collectWorkflowFindings(
|
|
76
59
|
path: string,
|
|
77
60
|
text: string,
|
|
@@ -131,12 +114,6 @@ function getJobLine(lines: readonly string[], jobsLine: number, id: string): num
|
|
|
131
114
|
return offset === -1 ? Math.max(jobsLine + 1, 1) : jobsLine + offset + 2;
|
|
132
115
|
}
|
|
133
116
|
|
|
134
|
-
function findMember(value: unknown, key: string): unknown {
|
|
135
|
-
return typeof value === 'object' && value !== null && Object.hasOwn(value, key)
|
|
136
|
-
? Reflect.get(value, key)
|
|
137
|
-
: undefined;
|
|
138
|
-
}
|
|
139
|
-
|
|
140
117
|
function collectTriggers(on: unknown): Set<string> {
|
|
141
118
|
if (typeof on === 'string') {
|
|
142
119
|
return new Set([on]);
|
|
@@ -231,8 +208,6 @@ function collectTimeoutFindings(context: WorkflowContext, job: Job): WorkflowFin
|
|
|
231
208
|
: [];
|
|
232
209
|
}
|
|
233
210
|
|
|
234
|
-
const pullRequestTriggers = ['pull_request', 'pull_request_target'];
|
|
235
|
-
|
|
236
211
|
// ci-design states the rule for a private repo only, so a public run leaves it alone.
|
|
237
212
|
function collectHeavyFindings(context: WorkflowContext, job: Job): WorkflowFinding[] {
|
|
238
213
|
const trigger = pullRequestTriggers.find((name) => context.triggers.has(name));
|
|
@@ -346,176 +321,6 @@ function collectCheckoutFindings(context: WorkflowContext, job: Job): WorkflowFi
|
|
|
346
321
|
});
|
|
347
322
|
}
|
|
348
323
|
|
|
349
|
-
function collectConcurrencyFindings(context: WorkflowContext): WorkflowFinding[] {
|
|
350
|
-
const deployJobs = context.jobs.filter((job) => isDeployJob(job));
|
|
351
|
-
const workflowConcurrency = findMember(context.workflow, 'concurrency');
|
|
352
|
-
|
|
353
|
-
return [
|
|
354
|
-
...deployJobs.flatMap((job) => collectDeployFindings(context, job, workflowConcurrency)),
|
|
355
|
-
...(isChecksWorkflow(context)
|
|
356
|
-
? collectChecksFindings(context, deployJobs, workflowConcurrency)
|
|
357
|
-
: []),
|
|
358
|
-
];
|
|
359
|
-
}
|
|
360
|
-
|
|
361
|
-
const deployJobPattern = /release|deploy|migrate/iu;
|
|
362
|
-
|
|
363
|
-
function isDeployJob(job: Job): boolean {
|
|
364
|
-
const name = findMember(job.definition, 'name');
|
|
365
|
-
|
|
366
|
-
return deployJobPattern.test(job.id) || (typeof name === 'string' && deployJobPattern.test(name));
|
|
367
|
-
}
|
|
368
|
-
|
|
369
|
-
function collectDeployFindings(
|
|
370
|
-
context: WorkflowContext,
|
|
371
|
-
job: Job,
|
|
372
|
-
workflowConcurrency: unknown,
|
|
373
|
-
): WorkflowFinding[] {
|
|
374
|
-
const concurrency = findMember(job.definition, 'concurrency');
|
|
375
|
-
|
|
376
|
-
const hasOwnGroup =
|
|
377
|
-
typeof findMember(concurrency, 'group') === 'string' &&
|
|
378
|
-
findMember(concurrency, 'cancel-in-progress') === false;
|
|
379
|
-
|
|
380
|
-
return [
|
|
381
|
-
...(hasOwnGroup
|
|
382
|
-
? []
|
|
383
|
-
: [
|
|
384
|
-
{
|
|
385
|
-
path: context.path,
|
|
386
|
-
line: job.line,
|
|
387
|
-
message: `job ${job.id} releases or deploys, so it sets a job-level concurrency group with cancel-in-progress: false`,
|
|
388
|
-
},
|
|
389
|
-
]),
|
|
390
|
-
...(isCancelling(workflowConcurrency)
|
|
391
|
-
? [
|
|
392
|
-
{
|
|
393
|
-
path: context.path,
|
|
394
|
-
line: getTopLevelLine(context.lines, 'concurrency'),
|
|
395
|
-
message: `the workflow-level cancel-in-progress: true can cancel job ${job.id}, which releases or deploys; set each concurrency group per job`,
|
|
396
|
-
},
|
|
397
|
-
]
|
|
398
|
-
: []),
|
|
399
|
-
];
|
|
400
|
-
}
|
|
401
|
-
|
|
402
|
-
function isCancelling(concurrency: unknown): boolean {
|
|
403
|
-
return findMember(concurrency, 'cancel-in-progress') === true;
|
|
404
|
-
}
|
|
405
|
-
|
|
406
|
-
function getTopLevelLine(lines: readonly string[], key: string): number {
|
|
407
|
-
const keyPattern = new RegExp(`^['"]?${RegExp.escape(key)}['"]?:`, 'u');
|
|
408
|
-
|
|
409
|
-
return lines.findIndex((line) => keyPattern.test(line)) + 1 || 1;
|
|
410
|
-
}
|
|
411
|
-
|
|
412
|
-
function isChecksWorkflow(context: WorkflowContext): boolean {
|
|
413
|
-
return context.triggers.has('pull_request') || context.triggers.has('push');
|
|
414
|
-
}
|
|
415
|
-
|
|
416
|
-
function collectChecksFindings(
|
|
417
|
-
context: WorkflowContext,
|
|
418
|
-
deployJobs: readonly Job[],
|
|
419
|
-
workflowConcurrency: unknown,
|
|
420
|
-
): WorkflowFinding[] {
|
|
421
|
-
const otherJobs = context.jobs.filter((job) => !deployJobs.includes(job));
|
|
422
|
-
|
|
423
|
-
const isEveryJobCovered =
|
|
424
|
-
otherJobs.length > 0 &&
|
|
425
|
-
otherJobs.every((job) => isChecksGroup(context, findMember(job.definition, 'concurrency')));
|
|
426
|
-
|
|
427
|
-
const isCovered = isChecksGroup(context, workflowConcurrency) || isEveryJobCovered;
|
|
428
|
-
|
|
429
|
-
const workflowFindings =
|
|
430
|
-
deployJobs.length > 0 || isCovered
|
|
431
|
-
? []
|
|
432
|
-
: [
|
|
433
|
-
{
|
|
434
|
-
path: context.path,
|
|
435
|
-
line: getTopLevelLine(context.lines, 'on'),
|
|
436
|
-
message:
|
|
437
|
-
'a checks workflow, which runs on pull_request or push, sets concurrency with cancel-in-progress: true; a gate that runs only on a push to main may instead group by github.sha and never cancel',
|
|
438
|
-
},
|
|
439
|
-
];
|
|
440
|
-
|
|
441
|
-
const jobFindings =
|
|
442
|
-
deployJobs.length === 0
|
|
443
|
-
? []
|
|
444
|
-
: otherJobs
|
|
445
|
-
.filter((job) => !isChecksGroup(context, findMember(job.definition, 'concurrency')))
|
|
446
|
-
.map((job) => ({
|
|
447
|
-
path: context.path,
|
|
448
|
-
line: job.line,
|
|
449
|
-
message: `job ${job.id} runs in a checks workflow beside a release or deploy job, so it sets a job-level concurrency group with cancel-in-progress: true`,
|
|
450
|
-
}));
|
|
451
|
-
|
|
452
|
-
return [
|
|
453
|
-
...workflowFindings,
|
|
454
|
-
...jobFindings,
|
|
455
|
-
...collectPerRunGroupFindings(
|
|
456
|
-
context,
|
|
457
|
-
workflowConcurrency,
|
|
458
|
-
getTopLevelLine(context.lines, 'concurrency'),
|
|
459
|
-
),
|
|
460
|
-
...otherJobs.flatMap((job) =>
|
|
461
|
-
collectPerRunGroupFindings(context, findMember(job.definition, 'concurrency'), job.line),
|
|
462
|
-
),
|
|
463
|
-
];
|
|
464
|
-
}
|
|
465
|
-
|
|
466
|
-
function isChecksGroup(context: WorkflowContext, concurrency: unknown): boolean {
|
|
467
|
-
return isCancelling(concurrency) || isGateGroup(context, concurrency);
|
|
468
|
-
}
|
|
469
|
-
|
|
470
|
-
function isGateGroup(context: WorkflowContext, concurrency: unknown): boolean {
|
|
471
|
-
const group = findMember(concurrency, 'group');
|
|
472
|
-
const cancel = findMember(concurrency, 'cancel-in-progress');
|
|
473
|
-
|
|
474
|
-
return (
|
|
475
|
-
isMainGate(context) &&
|
|
476
|
-
typeof group === 'string' &&
|
|
477
|
-
collectExpressionReferences(group).includes('github.sha') &&
|
|
478
|
-
(cancel === undefined || cancel === false)
|
|
479
|
-
);
|
|
480
|
-
}
|
|
481
|
-
|
|
482
|
-
// A gate on main runs once for each commit, so a newer commit must not cancel the older run.
|
|
483
|
-
function isMainGate(context: WorkflowContext): boolean {
|
|
484
|
-
const push = findMember(findMember(context.workflow, 'on'), 'push');
|
|
485
|
-
const branches = [findMember(push, 'branches')].flat();
|
|
486
|
-
|
|
487
|
-
return (
|
|
488
|
-
!pullRequestTriggers.some((name) => context.triggers.has(name)) &&
|
|
489
|
-
branches.length > 0 &&
|
|
490
|
-
branches.every((branch) => branch === 'main')
|
|
491
|
-
);
|
|
492
|
-
}
|
|
493
|
-
|
|
494
|
-
// A value that differs between two runs on the same branch gives each run its own group, which no
|
|
495
|
-
// later run cancels.
|
|
496
|
-
const perRunValues = ['github.sha', 'github.run_id', 'github.run_number', 'github.run_attempt'];
|
|
497
|
-
|
|
498
|
-
function collectPerRunGroupFindings(
|
|
499
|
-
context: WorkflowContext,
|
|
500
|
-
concurrency: unknown,
|
|
501
|
-
line: number,
|
|
502
|
-
): WorkflowFinding[] {
|
|
503
|
-
const group = findMember(concurrency, 'group');
|
|
504
|
-
const references = typeof group === 'string' ? collectExpressionReferences(group) : [];
|
|
505
|
-
const allowed = isGateGroup(context, concurrency) ? 'github.sha' : undefined;
|
|
506
|
-
const perRun = perRunValues.find((value) => value !== allowed && references.includes(value));
|
|
507
|
-
|
|
508
|
-
return perRun === undefined
|
|
509
|
-
? []
|
|
510
|
-
: [
|
|
511
|
-
{
|
|
512
|
-
path: context.path,
|
|
513
|
-
line,
|
|
514
|
-
message: `the concurrency group holds ${perRun}, so no newer run cancels this one`,
|
|
515
|
-
},
|
|
516
|
-
];
|
|
517
|
-
}
|
|
518
|
-
|
|
519
324
|
function collectWorkflowRunFindings(context: WorkflowContext): WorkflowFinding[] {
|
|
520
325
|
if (!context.triggers.has('workflow_run')) {
|
|
521
326
|
return [];
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import type { ChecksProfile } from './read-checks-profile.ts';
|
|
2
|
+
|
|
3
|
+
export interface WorkflowContext {
|
|
4
|
+
readonly path: string;
|
|
5
|
+
readonly profile: ChecksProfile | undefined;
|
|
6
|
+
readonly workflow: unknown;
|
|
7
|
+
readonly lines: readonly string[];
|
|
8
|
+
readonly jobs: readonly Job[];
|
|
9
|
+
readonly triggers: ReadonlySet<string>;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export interface Job {
|
|
13
|
+
readonly id: string;
|
|
14
|
+
readonly definition: unknown;
|
|
15
|
+
readonly line: number;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export interface WorkflowFinding {
|
|
19
|
+
// Relative to the repo root.
|
|
20
|
+
readonly path: string;
|
|
21
|
+
|
|
22
|
+
readonly line: number;
|
|
23
|
+
readonly message: string;
|
|
24
|
+
}
|