browser-commander 0.16.1 → 0.17.0
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/.changeset/config.json +1 -1
- package/.changeset/merged-loud-river.md +7 -0
- package/.jscpd-baseline.json +257 -0
- package/.jscpd.json +1 -3
- package/.prettierignore +1 -0
- package/CHANGELOG.md +8 -0
- package/eslint.config.js +23 -1
- package/package.json +13 -24
- package/scripts/changeset-version.mjs +2 -6
- package/scripts/create-github-release.mjs +7 -8
- package/scripts/create-manual-changeset.mjs +7 -8
- package/scripts/format-github-release.mjs +6 -7
- package/scripts/format-release-notes.mjs +7 -8
- package/scripts/instant-version-bump.mjs +7 -8
- package/scripts/publish-to-npm.mjs +7 -8
- package/scripts/setup-npm.mjs +2 -6
- package/scripts/version-and-commit.mjs +7 -8
- package/src/browser/browser-cookie-credentials.js +3 -1
- package/src/browser/browser-cookies.js +6 -2
- package/src/browser/browser-profiles.js +1 -1
- package/src/browser/launch-options.js +31 -8
- package/src/browser/launcher.js +16 -0
- package/src/exports.js +34 -0
- package/src/fingerprint/apply.js +116 -0
- package/src/fingerprint/automation-parity.js +224 -0
- package/src/fingerprint/cdp-overrides.js +176 -0
- package/src/fingerprint/derive.js +104 -0
- package/src/fingerprint/init-payload.js +181 -0
- package/src/fingerprint/init-script.js +104 -0
- package/src/fingerprint/limitations.js +107 -0
- package/src/fingerprint/limitations.json +96 -0
- package/src/fingerprint/presets.js +244 -0
- package/src/fingerprint/profile.js +428 -0
- package/src/tests/index.js +2 -1
- package/tests/e2e/fingerprint-parity.e2e.test.js +212 -0
- package/tests/helpers/mocks.js +4 -2
- package/tests/helpers/repo.js +74 -0
- package/tests/unit/browser/launch-options.test.js +9 -1
- package/tests/unit/experiments/fingerprint-parity-harness.test.js +97 -0
- package/tests/unit/fingerprint/apply.test.js +271 -0
- package/tests/unit/fingerprint/automation-parity.test.js +212 -0
- package/tests/unit/fingerprint/cdp-overrides.test.js +227 -0
- package/tests/unit/fingerprint/derive.test.js +105 -0
- package/tests/unit/fingerprint/init-script.test.js +357 -0
- package/tests/unit/fingerprint/limitations.test.js +184 -0
- package/tests/unit/fingerprint/presets.test.js +135 -0
- package/tests/unit/fingerprint/profile.test.js +282 -0
- package/tests/unit/scripts/check-pipeline-status.test.js +133 -0
- package/tests/unit/scripts/check-web-archive.test.js +191 -0
- package/tests/unit/scripts/ci-timeout-budgets.test.js +311 -0
- package/tests/unit/scripts/ci-workflow-policy.test.js +193 -157
- package/tests/unit/scripts/debug-print.test.js +67 -0
- package/tests/unit/scripts/jscpd-config.test.js +141 -0
- package/tests/unit/scripts/pre-commit-config.test.js +209 -0
- package/tests/unit/scripts/read-manifest.test.js +194 -0
- package/tests/unit/scripts/run-with-budget-warning.test.js +115 -0
- package/tests/unit/scripts/use-module-adoption.test.js +74 -0
- package/tests/unit/scripts/use-module-integration.test.js +64 -0
- package/tests/unit/scripts/use-module.test.js +182 -0
- package/.husky/pre-commit +0 -1
- package/.prettierrc +0 -10
|
@@ -0,0 +1,311 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `timeout-minutes` is a backstop, never a deadline.
|
|
3
|
+
*
|
|
4
|
+
* GitHub reports a job killed by `timeout-minutes` as *cancelled*, not
|
|
5
|
+
* *failed*, so an overrun used to leave no red check anywhere (see
|
|
6
|
+
* docs/CI-TIMEOUT-BUDGETS.md). Two things fix that: long steps own an explicit
|
|
7
|
+
* budget through scripts/run-with-budget-warning.sh, and every workflow ends in
|
|
8
|
+
* a `pipeline-status` job that reads the results of all the others.
|
|
9
|
+
*
|
|
10
|
+
* These assertions are what find the *next* occurrence instead of the one that
|
|
11
|
+
* already happened: a budget raised above its job's backstop, or a new job that
|
|
12
|
+
* the gate does not watch, fails here rather than in production.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import assert from 'node:assert/strict';
|
|
16
|
+
import { readFileSync, readdirSync } from 'node:fs';
|
|
17
|
+
import { describe, it } from 'node:test';
|
|
18
|
+
|
|
19
|
+
import { repoPath } from '../../helpers/repo.js';
|
|
20
|
+
|
|
21
|
+
const WORKFLOW_DIR = repoPath('.github/workflows');
|
|
22
|
+
|
|
23
|
+
// A budget must expire with room to spare before the backstop fires, because
|
|
24
|
+
// checkout, toolchain installation and dependency installs run on the same job
|
|
25
|
+
// clock and are not budgeted. The share matches BUDGET_WARN_PERCENT in
|
|
26
|
+
// scripts/run-with-budget-warning.sh.
|
|
27
|
+
const MAX_BUDGET_SHARE_PERCENT = 70;
|
|
28
|
+
|
|
29
|
+
const GATE_JOB = 'pipeline-status';
|
|
30
|
+
const GATE_SCRIPT = 'scripts/check-pipeline-status.sh';
|
|
31
|
+
|
|
32
|
+
function readWorkflow(fileName) {
|
|
33
|
+
return readFileSync(
|
|
34
|
+
repoPath('.github/workflows', fileName),
|
|
35
|
+
'utf8'
|
|
36
|
+
).replaceAll('\r\n', '\n');
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function listWorkflowFiles() {
|
|
40
|
+
return readdirSync(WORKFLOW_DIR)
|
|
41
|
+
.filter((fileName) => fileName.endsWith('.yml'))
|
|
42
|
+
.sort();
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// Only the mapping under `jobs:` is scanned, so the two-space keys of `on:`
|
|
46
|
+
// (`push:`, `pull_request:`) are not mistaken for job names.
|
|
47
|
+
function listWorkflowJobs(workflow) {
|
|
48
|
+
const jobsStart = workflow.indexOf('\njobs:\n');
|
|
49
|
+
const jobsBody = jobsStart === -1 ? '' : workflow.slice(jobsStart);
|
|
50
|
+
|
|
51
|
+
return Array.from(
|
|
52
|
+
jobsBody.matchAll(/^ {2}([a-zA-Z0-9_-]+):\s*$/gm),
|
|
53
|
+
(match) => match[1]
|
|
54
|
+
);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function getJobBlock(workflow, jobName) {
|
|
58
|
+
const lines = workflow.split('\n');
|
|
59
|
+
const start = lines.indexOf(` ${jobName}:`);
|
|
60
|
+
|
|
61
|
+
if (start === -1) {
|
|
62
|
+
return '';
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const end = lines.findIndex(
|
|
66
|
+
(line, index) => index > start && /^ {2}[a-zA-Z0-9_-]+:\s*$/.test(line)
|
|
67
|
+
);
|
|
68
|
+
|
|
69
|
+
return lines.slice(start, end === -1 ? lines.length : end).join('\n');
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function getTimeoutMinutes(workflow, jobName) {
|
|
73
|
+
const timeout = getJobBlock(workflow, jobName).match(
|
|
74
|
+
/^ {4}timeout-minutes:\s*(\d+)\s*$/m
|
|
75
|
+
);
|
|
76
|
+
|
|
77
|
+
return timeout ? Number(timeout[1]) : undefined;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function getStepBudgets(workflow, jobName) {
|
|
81
|
+
return Array.from(
|
|
82
|
+
getJobBlock(workflow, jobName).matchAll(
|
|
83
|
+
/run-with-budget-warning\.sh\s+(\d+)\s+"([^"]+)"/g
|
|
84
|
+
),
|
|
85
|
+
(match) => ({ label: match[2], seconds: Number(match[1]) })
|
|
86
|
+
);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function getJobNeeds(workflow, jobName) {
|
|
90
|
+
const block = getJobBlock(workflow, jobName);
|
|
91
|
+
const inline = block.match(/^ {4}needs:\s*\[([^\]]*)\]\s*$/m);
|
|
92
|
+
|
|
93
|
+
if (inline) {
|
|
94
|
+
return inline[1]
|
|
95
|
+
.split(',')
|
|
96
|
+
.map((name) => name.trim())
|
|
97
|
+
.filter(Boolean);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
const listStart = block.indexOf('\n needs:\n');
|
|
101
|
+
|
|
102
|
+
if (listStart === -1) {
|
|
103
|
+
return [];
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
const lines = block
|
|
107
|
+
.slice(listStart + 1)
|
|
108
|
+
.split('\n')
|
|
109
|
+
.slice(1);
|
|
110
|
+
const needs = [];
|
|
111
|
+
|
|
112
|
+
for (const line of lines) {
|
|
113
|
+
const item = line.match(/^ {6}- ([a-zA-Z0-9_-]+)\s*$/);
|
|
114
|
+
|
|
115
|
+
if (!item) {
|
|
116
|
+
break;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
needs.push(item[1]);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
return needs;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
const WORKFLOWS = listWorkflowFiles().map((fileName) => ({
|
|
126
|
+
fileName,
|
|
127
|
+
text: readWorkflow(fileName),
|
|
128
|
+
}));
|
|
129
|
+
|
|
130
|
+
describe('CI execution budgets', () => {
|
|
131
|
+
it('finds the workflows to check', () => {
|
|
132
|
+
assert.ok(
|
|
133
|
+
WORKFLOWS.length >= 9,
|
|
134
|
+
`expected the repository's workflows, found ${WORKFLOWS.length}`
|
|
135
|
+
);
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
it('keeps every declared step budget under its job backstop', () => {
|
|
139
|
+
const violations = [];
|
|
140
|
+
let budgetedJobs = 0;
|
|
141
|
+
|
|
142
|
+
for (const { fileName, text } of WORKFLOWS) {
|
|
143
|
+
for (const jobName of listWorkflowJobs(text)) {
|
|
144
|
+
const budgets = getStepBudgets(text, jobName);
|
|
145
|
+
|
|
146
|
+
if (budgets.length === 0) {
|
|
147
|
+
continue;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
budgetedJobs += 1;
|
|
151
|
+
|
|
152
|
+
const backstop = getTimeoutMinutes(text, jobName);
|
|
153
|
+
|
|
154
|
+
if (typeof backstop !== 'number') {
|
|
155
|
+
violations.push(`${fileName} ${jobName}: no timeout-minutes`);
|
|
156
|
+
continue;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
const allowedSeconds = Math.floor(
|
|
160
|
+
(backstop * 60 * MAX_BUDGET_SHARE_PERCENT) / 100
|
|
161
|
+
);
|
|
162
|
+
const totalSeconds = budgets.reduce(
|
|
163
|
+
(sum, budget) => sum + budget.seconds,
|
|
164
|
+
0
|
|
165
|
+
);
|
|
166
|
+
|
|
167
|
+
for (const budget of budgets) {
|
|
168
|
+
if (budget.seconds > allowedSeconds) {
|
|
169
|
+
violations.push(
|
|
170
|
+
`${fileName} ${jobName}: "${budget.label}" budget ${budget.seconds}s exceeds ${allowedSeconds}s (${MAX_BUDGET_SHARE_PERCENT}% of the ${backstop}min backstop)`
|
|
171
|
+
);
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
if (totalSeconds > allowedSeconds) {
|
|
176
|
+
violations.push(
|
|
177
|
+
`${fileName} ${jobName}: budgets total ${totalSeconds}s, exceeding ${allowedSeconds}s (${MAX_BUDGET_SHARE_PERCENT}% of the ${backstop}min backstop)`
|
|
178
|
+
);
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
assert.deepEqual(violations, []);
|
|
184
|
+
assert.ok(budgetedJobs > 0, 'no job declares a step budget');
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
it('wraps the long steps that used to be able to run past a backstop', () => {
|
|
188
|
+
const budgeted = WORKFLOWS.flatMap(({ fileName, text }) =>
|
|
189
|
+
listWorkflowJobs(text).flatMap((jobName) =>
|
|
190
|
+
getStepBudgets(text, jobName).map(
|
|
191
|
+
(budget) => `${fileName} ${jobName} ${budget.label}`
|
|
192
|
+
)
|
|
193
|
+
)
|
|
194
|
+
).sort();
|
|
195
|
+
|
|
196
|
+
assert.deepEqual(budgeted, [
|
|
197
|
+
'docs.yml build-docs Rust API docs',
|
|
198
|
+
'js.yml test Node.js test suite',
|
|
199
|
+
'parity.yml parity Fingerprint parity suite',
|
|
200
|
+
'python.yml test pytest suite',
|
|
201
|
+
'rust.yml coverage Rust code coverage',
|
|
202
|
+
'rust.yml test Rust doc tests',
|
|
203
|
+
'rust.yml test Rust test suite',
|
|
204
|
+
]);
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
it('warns at the same share of a budget that this invariant allows', () => {
|
|
208
|
+
const wrapper = readFileSync(
|
|
209
|
+
repoPath('scripts/run-with-budget-warning.sh'),
|
|
210
|
+
'utf8'
|
|
211
|
+
);
|
|
212
|
+
|
|
213
|
+
assert.match(
|
|
214
|
+
wrapper,
|
|
215
|
+
new RegExp(`BUDGET_WARN_PERCENT:-${MAX_BUDGET_SHARE_PERCENT}`)
|
|
216
|
+
);
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
it('runs the wrapper under bash, not the Windows default shell', () => {
|
|
220
|
+
for (const { fileName, text } of WORKFLOWS) {
|
|
221
|
+
const lines = text.split('\n');
|
|
222
|
+
|
|
223
|
+
lines.forEach((line, index) => {
|
|
224
|
+
if (!line.includes('run-with-budget-warning.sh')) {
|
|
225
|
+
return;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
const step = lines
|
|
229
|
+
.slice(Math.max(0, index - 12), index + 1)
|
|
230
|
+
.join('\n')
|
|
231
|
+
.split(/^ {6}- /m)
|
|
232
|
+
.pop();
|
|
233
|
+
|
|
234
|
+
assert.ok(
|
|
235
|
+
/shell: bash/.test(step),
|
|
236
|
+
`${fileName}:${index + 1} wraps a command without "shell: bash"; the default shell on windows-latest is pwsh`
|
|
237
|
+
);
|
|
238
|
+
});
|
|
239
|
+
}
|
|
240
|
+
});
|
|
241
|
+
});
|
|
242
|
+
|
|
243
|
+
describe('pipeline status gate', () => {
|
|
244
|
+
it('is present in every workflow and needs every other job', () => {
|
|
245
|
+
for (const { fileName, text } of WORKFLOWS) {
|
|
246
|
+
const jobs = listWorkflowJobs(text);
|
|
247
|
+
|
|
248
|
+
assert.ok(
|
|
249
|
+
jobs.includes(GATE_JOB),
|
|
250
|
+
`${fileName} has no ${GATE_JOB} job, so a cancelled job there would go unreported`
|
|
251
|
+
);
|
|
252
|
+
|
|
253
|
+
const watched = getJobNeeds(text, GATE_JOB).sort();
|
|
254
|
+
const expected = jobs.filter((jobName) => jobName !== GATE_JOB).sort();
|
|
255
|
+
|
|
256
|
+
assert.deepEqual(
|
|
257
|
+
watched,
|
|
258
|
+
expected,
|
|
259
|
+
`${fileName}: ${GATE_JOB} does not watch every job`
|
|
260
|
+
);
|
|
261
|
+
}
|
|
262
|
+
});
|
|
263
|
+
|
|
264
|
+
it('runs the gate script after a failed dependency', () => {
|
|
265
|
+
for (const { fileName, text } of WORKFLOWS) {
|
|
266
|
+
const block = getJobBlock(text, GATE_JOB);
|
|
267
|
+
|
|
268
|
+
assert.ok(
|
|
269
|
+
block.includes(`run: bash ${GATE_SCRIPT}`),
|
|
270
|
+
`${fileName}: ${GATE_JOB} does not run ${GATE_SCRIPT}`
|
|
271
|
+
);
|
|
272
|
+
|
|
273
|
+
// Without a status function the gate inherits "every dependency
|
|
274
|
+
// succeeded" and is skipped in exactly the runs it exists to report.
|
|
275
|
+
assert.match(
|
|
276
|
+
block,
|
|
277
|
+
/if: >-\n\s*!cancelled\(\)/,
|
|
278
|
+
`${fileName}: ${GATE_JOB} must be conditioned on !cancelled()`
|
|
279
|
+
);
|
|
280
|
+
|
|
281
|
+
assert.match(
|
|
282
|
+
block,
|
|
283
|
+
/NEEDS_JSON: \$\{\{ toJSON\(needs\) \}\}/,
|
|
284
|
+
`${fileName}: ${GATE_JOB} does not pass the job results to the script`
|
|
285
|
+
);
|
|
286
|
+
|
|
287
|
+
assert.match(
|
|
288
|
+
block,
|
|
289
|
+
/RUN_SHA: \$\{\{ github\.sha \}\}/,
|
|
290
|
+
`${fileName}: ${GATE_JOB} cannot tell a supersede from an overrun without RUN_SHA`
|
|
291
|
+
);
|
|
292
|
+
}
|
|
293
|
+
});
|
|
294
|
+
|
|
295
|
+
it('reads the gate script from the repository root in scoped workflows', () => {
|
|
296
|
+
// js.yml, python.yml and rust.yml set defaults.run.working-directory to the
|
|
297
|
+
// language subdirectory, where scripts/check-pipeline-status.sh does not
|
|
298
|
+
// exist.
|
|
299
|
+
for (const { fileName, text } of WORKFLOWS) {
|
|
300
|
+
if (!/^defaults:\n {2}run:\n {4}working-directory: \S+$/m.test(text)) {
|
|
301
|
+
continue;
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
assert.match(
|
|
305
|
+
getJobBlock(text, GATE_JOB),
|
|
306
|
+
/working-directory: \.$/m,
|
|
307
|
+
`${fileName}: ${GATE_JOB} runs in the language subdirectory, where ${GATE_SCRIPT} is not`
|
|
308
|
+
);
|
|
309
|
+
}
|
|
310
|
+
});
|
|
311
|
+
});
|
|
@@ -18,32 +18,82 @@ function withWorkflow(content, callback) {
|
|
|
18
18
|
}
|
|
19
19
|
}
|
|
20
20
|
|
|
21
|
+
/**
|
|
22
|
+
* Build a workflow that already satisfies every unrelated policy rule.
|
|
23
|
+
*
|
|
24
|
+
* Each test then varies exactly one thing, so a failure count of N names the N
|
|
25
|
+
* rules under test instead of the scaffolding around them.
|
|
26
|
+
*
|
|
27
|
+
* @param {{on?: string, preamble?: string, job?: string, steps: string}} parts
|
|
28
|
+
* @returns {string}
|
|
29
|
+
*/
|
|
30
|
+
function workflow({ on = 'push', preamble = '', job = 'test', steps }) {
|
|
31
|
+
return [
|
|
32
|
+
'name: Test',
|
|
33
|
+
`on: ${on}`,
|
|
34
|
+
preamble,
|
|
35
|
+
'env:',
|
|
36
|
+
' GIT_CONFIG_KEY_0: init.defaultBranch',
|
|
37
|
+
'jobs:',
|
|
38
|
+
` ${job}:`,
|
|
39
|
+
' runs-on: ubuntu-latest',
|
|
40
|
+
' timeout-minutes: 5',
|
|
41
|
+
' concurrency:',
|
|
42
|
+
` group: \${{ github.workflow }}-\${{ github.ref }}-${job}`,
|
|
43
|
+
' cancel-in-progress: true',
|
|
44
|
+
' steps:',
|
|
45
|
+
' - uses: actions/checkout@v6',
|
|
46
|
+
steps,
|
|
47
|
+
// Every workflow has to end in the gate that reads the other jobs'
|
|
48
|
+
// results, so the scaffolding carries one; the gate's own tests are in
|
|
49
|
+
// ci-timeout-budgets.test.js.
|
|
50
|
+
' pipeline-status:',
|
|
51
|
+
' runs-on: ubuntu-latest',
|
|
52
|
+
' timeout-minutes: 5',
|
|
53
|
+
' concurrency:',
|
|
54
|
+
' group: ${{ github.workflow }}-${{ github.ref }}-pipeline-status',
|
|
55
|
+
' cancel-in-progress: true',
|
|
56
|
+
` needs: [${job}]`,
|
|
57
|
+
' steps:',
|
|
58
|
+
' - run: bash scripts/check-pipeline-status.sh',
|
|
59
|
+
'',
|
|
60
|
+
]
|
|
61
|
+
.filter((section) => section !== '')
|
|
62
|
+
.join('\n');
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Count policy failures without letting the reported ::error:: lines leak into
|
|
67
|
+
* the test output, where they read like real CI annotations.
|
|
68
|
+
*
|
|
69
|
+
* @param {string} content
|
|
70
|
+
* @returns {number}
|
|
71
|
+
*/
|
|
72
|
+
function countFailures(content) {
|
|
73
|
+
const originalError = console.error;
|
|
74
|
+
console.error = () => {};
|
|
75
|
+
|
|
76
|
+
try {
|
|
77
|
+
return withWorkflow(content, checkWorkflow);
|
|
78
|
+
} finally {
|
|
79
|
+
console.error = originalError;
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
21
83
|
describe('CI workflow policy', () => {
|
|
22
84
|
it('accepts bounded job-scoped checks', () => {
|
|
23
|
-
const
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
env:
|
|
28
|
-
GIT_CONFIG_KEY_0: init.defaultBranch
|
|
29
|
-
jobs:
|
|
30
|
-
test:
|
|
31
|
-
runs-on: ubuntu-latest
|
|
32
|
-
timeout-minutes: 5
|
|
33
|
-
concurrency:
|
|
34
|
-
group: \${{ github.workflow }}-\${{ github.ref }}-test
|
|
35
|
-
cancel-in-progress: true
|
|
36
|
-
steps:
|
|
37
|
-
- uses: actions/checkout@v6
|
|
38
|
-
`;
|
|
85
|
+
const content = workflow({
|
|
86
|
+
preamble: 'permissions:\n contents: read',
|
|
87
|
+
steps: ' - run: npm ci --ignore-scripts',
|
|
88
|
+
});
|
|
39
89
|
|
|
40
|
-
withWorkflow(
|
|
90
|
+
withWorkflow(content, (filePath) => {
|
|
41
91
|
assert.equal(checkWorkflow(filePath), 0);
|
|
42
92
|
});
|
|
43
93
|
});
|
|
44
94
|
|
|
45
95
|
it('rejects workflow-wide cancellation and unbounded jobs', () => {
|
|
46
|
-
const
|
|
96
|
+
const content = `name: Test
|
|
47
97
|
on: push
|
|
48
98
|
concurrency:
|
|
49
99
|
group: test
|
|
@@ -59,168 +109,154 @@ jobs:
|
|
|
59
109
|
with:
|
|
60
110
|
file: coverage.xml
|
|
61
111
|
`;
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
assert.equal(checkWorkflow(filePath), 7);
|
|
68
|
-
});
|
|
69
|
-
} finally {
|
|
70
|
-
console.error = originalError;
|
|
71
|
-
}
|
|
112
|
+
|
|
113
|
+
// The eighth is the missing pipeline-status gate: with no job reading the
|
|
114
|
+
// other jobs' results, a check killed by timeout-minutes is reported as
|
|
115
|
+
// cancelled and fails nothing.
|
|
116
|
+
assert.equal(countFailures(content), 8);
|
|
72
117
|
});
|
|
73
118
|
|
|
74
119
|
it('rejects untrusted expressions interpolated into run bodies', () => {
|
|
75
|
-
const
|
|
76
|
-
on:
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
concurrency:
|
|
92
|
-
group: \${{ github.workflow }}-\${{ github.ref }}-release
|
|
93
|
-
cancel-in-progress: true
|
|
94
|
-
steps:
|
|
95
|
-
- uses: actions/checkout@v6
|
|
96
|
-
- run: echo "\${{ github.base_ref }}"
|
|
97
|
-
- run: bump "\${{ github.event.inputs.bump_type }}" "\${{ github.event.inputs.description }}"
|
|
98
|
-
- uses: peter-evans/create-pull-request@v7
|
|
99
|
-
with:
|
|
100
|
-
title: 'release \${{ github.event.inputs.description }}'
|
|
101
|
-
`;
|
|
102
|
-
const originalError = console.error;
|
|
103
|
-
console.error = () => {};
|
|
104
|
-
|
|
105
|
-
try {
|
|
106
|
-
withWorkflow(workflow, (filePath) => {
|
|
107
|
-
// One github.base_ref interpolation, one free-form input inside a run
|
|
108
|
-
// body (bump_type is a choice input, and the create-pull-request title
|
|
109
|
-
// is an action input rather than a shell body), one outdated action.
|
|
110
|
-
assert.equal(checkWorkflow(filePath), 3);
|
|
111
|
-
});
|
|
112
|
-
} finally {
|
|
113
|
-
console.error = originalError;
|
|
114
|
-
}
|
|
120
|
+
const content = workflow({
|
|
121
|
+
on: '\n workflow_dispatch:\n inputs:\n bump_type:\n type: choice\n options:\n - patch\n description:\n type: string',
|
|
122
|
+
job: 'release',
|
|
123
|
+
steps: [
|
|
124
|
+
' - run: echo "${{ github.base_ref }}"',
|
|
125
|
+
' - run: bump "${{ github.event.inputs.bump_type }}" "${{ github.event.inputs.description }}"',
|
|
126
|
+
' - uses: peter-evans/create-pull-request@v7',
|
|
127
|
+
' with:',
|
|
128
|
+
" title: 'release ${{ github.event.inputs.description }}'",
|
|
129
|
+
].join('\n'),
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
// One github.base_ref interpolation, one free-form input inside a run body
|
|
133
|
+
// (bump_type is a choice input, and the create-pull-request title is an
|
|
134
|
+
// action input rather than a shell body), one outdated action.
|
|
135
|
+
assert.equal(countFailures(content), 3);
|
|
115
136
|
});
|
|
116
137
|
|
|
117
138
|
it('rejects a condition that starts with the YAML tag indicator', () => {
|
|
118
|
-
const
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
runs-on: ubuntu-latest
|
|
125
|
-
timeout-minutes: 5
|
|
126
|
-
concurrency:
|
|
127
|
-
group: \${{ github.workflow }}-\${{ github.ref }}-test
|
|
128
|
-
cancel-in-progress: true
|
|
129
|
-
if: !cancelled() && github.event_name == 'push'
|
|
130
|
-
steps:
|
|
131
|
-
- uses: actions/checkout@v6
|
|
132
|
-
`;
|
|
133
|
-
const originalError = console.error;
|
|
134
|
-
console.error = () => {};
|
|
135
|
-
|
|
136
|
-
try {
|
|
137
|
-
withWorkflow(workflow, (filePath) => {
|
|
138
|
-
assert.equal(checkWorkflow(filePath), 1);
|
|
139
|
-
});
|
|
140
|
-
} finally {
|
|
141
|
-
console.error = originalError;
|
|
142
|
-
}
|
|
139
|
+
const content = workflow({
|
|
140
|
+
steps:
|
|
141
|
+
" - run: echo ok\n if: !cancelled() && github.event_name == 'push'",
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
assert.equal(countFailures(content), 1);
|
|
143
145
|
});
|
|
144
146
|
|
|
145
147
|
it('rejects outdated setup-python and codecov versions', () => {
|
|
146
|
-
const
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
concurrency:
|
|
155
|
-
group: \${{ github.workflow }}-\${{ github.ref }}-test
|
|
156
|
-
cancel-in-progress: true
|
|
157
|
-
steps:
|
|
158
|
-
- uses: actions/checkout@v6
|
|
159
|
-
- uses: actions/setup-python@v5
|
|
160
|
-
- uses: codecov/codecov-action@v6
|
|
161
|
-
`;
|
|
162
|
-
const originalError = console.error;
|
|
163
|
-
console.error = () => {};
|
|
164
|
-
|
|
165
|
-
try {
|
|
166
|
-
withWorkflow(workflow, (filePath) => {
|
|
167
|
-
assert.equal(checkWorkflow(filePath), 2);
|
|
168
|
-
});
|
|
169
|
-
} finally {
|
|
170
|
-
console.error = originalError;
|
|
171
|
-
}
|
|
148
|
+
const content = workflow({
|
|
149
|
+
steps: [
|
|
150
|
+
' - uses: actions/setup-python@v5',
|
|
151
|
+
' - uses: codecov/codecov-action@v6',
|
|
152
|
+
].join('\n'),
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
assert.equal(countFailures(content), 2);
|
|
172
156
|
});
|
|
173
157
|
|
|
174
158
|
it('accepts a base_ref bound to any environment variable name', () => {
|
|
175
|
-
const
|
|
176
|
-
on: pull_request
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
group: \${{ github.workflow }}-\${{ github.ref }}-test
|
|
185
|
-
cancel-in-progress: true
|
|
186
|
-
steps:
|
|
187
|
-
- uses: actions/checkout@v6
|
|
188
|
-
- name: Simulate fresh merge
|
|
189
|
-
env:
|
|
190
|
-
BASE_REF: \${{ github.base_ref }}
|
|
191
|
-
run: bash scripts/simulate-fresh-merge.sh
|
|
192
|
-
`;
|
|
159
|
+
const content = workflow({
|
|
160
|
+
on: 'pull_request',
|
|
161
|
+
steps: [
|
|
162
|
+
' - name: Simulate fresh merge',
|
|
163
|
+
' env:',
|
|
164
|
+
' BASE_REF: ${{ github.base_ref }}',
|
|
165
|
+
' run: bash scripts/simulate-fresh-merge.sh',
|
|
166
|
+
].join('\n'),
|
|
167
|
+
});
|
|
193
168
|
|
|
194
|
-
withWorkflow(
|
|
169
|
+
withWorkflow(content, (filePath) => {
|
|
195
170
|
assert.equal(checkWorkflow(filePath), 0);
|
|
196
171
|
});
|
|
197
172
|
});
|
|
198
173
|
|
|
199
174
|
it('still rejects a base_ref spliced into a run body', () => {
|
|
200
|
-
const
|
|
201
|
-
on: pull_request
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
175
|
+
const content = workflow({
|
|
176
|
+
on: 'pull_request',
|
|
177
|
+
steps: ' - run: git fetch origin ${{ github.base_ref }}',
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
assert.ok(countFailures(content) > 0);
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
it('rejects a version scraped out of pyproject.toml with grep', () => {
|
|
184
|
+
// The exact command that shipped in python.yml until this pull request.
|
|
185
|
+
// pyproject.toml carries `version` under [project] and under [tool.scriv],
|
|
186
|
+
// so this grep wrote two lines into $GITHUB_OUTPUT and failed the step.
|
|
187
|
+
const content = workflow({
|
|
188
|
+
steps: [
|
|
189
|
+
' - run: |',
|
|
190
|
+
` CURRENT_VERSION=$(grep -Po '(?<=^version = ")[^"]*' pyproject.toml)`,
|
|
191
|
+
' echo "current_version=$CURRENT_VERSION" >> $GITHUB_OUTPUT',
|
|
192
|
+
].join('\n'),
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
assert.equal(countFailures(content), 1);
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
it('rejects a crate name scraped out of Cargo.toml with head -1', () => {
|
|
199
|
+
// `head -1` is ordering-dependent, not table-aware: Cargo.toml repeats
|
|
200
|
+
// `name` under [[bin]] and [lib].
|
|
201
|
+
const content = workflow({
|
|
202
|
+
steps: ` - run: grep '^name' Cargo.toml | head -1 | cut -d'"' -f2`,
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
assert.equal(countFailures(content), 1);
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
it('rejects a workflow with no pipeline-status gate', () => {
|
|
209
|
+
// A job killed by timeout-minutes is reported as cancelled, and a run whose
|
|
210
|
+
// only casualty is a cancelled job is filed under cancelled too, so without
|
|
211
|
+
// this gate the overrun fails nothing.
|
|
212
|
+
const content = workflow({
|
|
213
|
+
steps: ' - run: npm ci --ignore-scripts',
|
|
214
|
+
}).replace(/ {2}pipeline-status:[\s\S]*$/, '');
|
|
215
|
+
|
|
216
|
+
assert.equal(countFailures(content), 1);
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
it('rejects a gate that has stopped watching a job', () => {
|
|
220
|
+
const content = `${workflow({
|
|
221
|
+
steps: ' - run: npm ci --ignore-scripts',
|
|
222
|
+
})}
|
|
223
|
+
drifted:
|
|
206
224
|
runs-on: ubuntu-latest
|
|
207
225
|
timeout-minutes: 5
|
|
208
226
|
concurrency:
|
|
209
|
-
group: \${{ github.workflow }}-\${{ github.ref }}-
|
|
227
|
+
group: \${{ github.workflow }}-\${{ github.ref }}-drifted
|
|
210
228
|
cancel-in-progress: true
|
|
211
229
|
steps:
|
|
212
|
-
-
|
|
213
|
-
- run: git fetch origin \${{ github.base_ref }}
|
|
230
|
+
- run: echo drifted
|
|
214
231
|
`;
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
232
|
+
|
|
233
|
+
assert.equal(countFailures(content), 1);
|
|
234
|
+
});
|
|
235
|
+
|
|
236
|
+
it('accepts the table-aware manifest readers', () => {
|
|
237
|
+
const content = workflow({
|
|
238
|
+
steps: [
|
|
239
|
+
' - run: python scripts/read_manifest.py pyproject.toml --output current_version',
|
|
240
|
+
' - run: node ../scripts/read-manifest.mjs Cargo.toml --field name',
|
|
241
|
+
].join('\n'),
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
withWorkflow(content, (filePath) => {
|
|
245
|
+
assert.equal(checkWorkflow(filePath), 0);
|
|
246
|
+
});
|
|
247
|
+
});
|
|
248
|
+
|
|
249
|
+
it('leaves a commented-out manifest grep alone', () => {
|
|
250
|
+
const content = workflow({
|
|
251
|
+
steps: [
|
|
252
|
+
' - run: |',
|
|
253
|
+
` # Replaced grep -Po '(?<=^version = ")[^"]*' pyproject.toml`,
|
|
254
|
+
' python scripts/read_manifest.py pyproject.toml --output version',
|
|
255
|
+
].join('\n'),
|
|
256
|
+
});
|
|
257
|
+
|
|
258
|
+
withWorkflow(content, (filePath) => {
|
|
259
|
+
assert.equal(checkWorkflow(filePath), 0);
|
|
260
|
+
});
|
|
225
261
|
});
|
|
226
262
|
});
|