browser-commander 0.16.1 → 0.17.1
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 +2 -1
- package/.jscpd-baseline.json +263 -0
- package/.jscpd.json +1 -3
- package/.prettierignore +1 -0
- package/CHANGELOG.md +19 -0
- package/eslint.config.js +23 -1
- package/package.json +13 -24
- package/scripts/changeset-version.mjs +2 -6
- package/scripts/check-release-format.mjs +80 -0
- package/scripts/create-github-release.mjs +33 -13
- 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 +18 -7
- 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/cargo-lock-sync.test.js +110 -0
- package/tests/unit/scripts/changelog-collection-order.test.js +144 -0
- package/tests/unit/scripts/changeset-formatter.test.js +108 -0
- package/tests/unit/scripts/check-pipeline-status.test.js +133 -0
- package/tests/unit/scripts/check-release-format.test.js +131 -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 +292 -153
- package/tests/unit/scripts/command-error-text.test.js +83 -0
- package/tests/unit/scripts/command-stream-errexit.test.js +132 -0
- 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 +214 -0
- package/tests/unit/scripts/read-manifest.test.js +194 -0
- package/tests/unit/scripts/release-tags.test.js +96 -0
- package/tests/unit/scripts/required-docs.test.js +145 -0
- package/tests/unit/scripts/run-with-budget-warning.test.js +115 -0
- package/tests/unit/scripts/rust-changelog.test.js +147 -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
- package/deno.json +0 -7
|
@@ -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,257 @@ jobs:
|
|
|
59
109
|
with:
|
|
60
110
|
file: coverage.xml
|
|
61
111
|
`;
|
|
62
|
-
const originalError = console.error;
|
|
63
|
-
console.error = () => {};
|
|
64
112
|
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
} finally {
|
|
70
|
-
console.error = originalError;
|
|
71
|
-
}
|
|
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
|
-
GIT_CONFIG_KEY_0: init.defaultBranch
|
|
87
|
-
jobs:
|
|
88
|
-
release:
|
|
89
|
-
runs-on: ubuntu-latest
|
|
90
|
-
timeout-minutes: 5
|
|
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 = () => {};
|
|
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
|
+
});
|
|
104
131
|
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
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
|
-
}
|
|
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
|
-
jobs:
|
|
123
|
-
test:
|
|
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 = () => {};
|
|
139
|
+
const content = workflow({
|
|
140
|
+
steps:
|
|
141
|
+
" - run: echo ok\n if: !cancelled() && github.event_name == 'push'",
|
|
142
|
+
});
|
|
135
143
|
|
|
136
|
-
|
|
137
|
-
withWorkflow(workflow, (filePath) => {
|
|
138
|
-
assert.equal(checkWorkflow(filePath), 1);
|
|
139
|
-
});
|
|
140
|
-
} finally {
|
|
141
|
-
console.error = originalError;
|
|
142
|
-
}
|
|
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
|
-
runs-on: ubuntu-latest
|
|
153
|
-
timeout-minutes: 5
|
|
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 = () => {};
|
|
148
|
+
const content = workflow({
|
|
149
|
+
steps: [
|
|
150
|
+
' - uses: actions/setup-python@v5',
|
|
151
|
+
' - uses: codecov/codecov-action@v6',
|
|
152
|
+
].join('\n'),
|
|
153
|
+
});
|
|
164
154
|
|
|
165
|
-
|
|
166
|
-
withWorkflow(workflow, (filePath) => {
|
|
167
|
-
assert.equal(checkWorkflow(filePath), 2);
|
|
168
|
-
});
|
|
169
|
-
} finally {
|
|
170
|
-
console.error = originalError;
|
|
171
|
-
}
|
|
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
|
+
});
|
|
261
|
+
});
|
|
262
|
+
});
|
|
263
|
+
|
|
264
|
+
/**
|
|
265
|
+
* Build a two-job workflow whose `test` job varies only in how it relates to
|
|
266
|
+
* `lint`, so the fast-fail rule is the only thing a failure count can mean.
|
|
267
|
+
*
|
|
268
|
+
* @param {{needs: string, condition?: string}} parts
|
|
269
|
+
* @returns {string}
|
|
270
|
+
*/
|
|
271
|
+
function lintAndTest({ needs, condition = '' }) {
|
|
272
|
+
return [
|
|
273
|
+
'name: Test',
|
|
274
|
+
'on: push',
|
|
275
|
+
'permissions:',
|
|
276
|
+
' contents: read',
|
|
277
|
+
'env:',
|
|
278
|
+
' GIT_CONFIG_KEY_0: init.defaultBranch',
|
|
279
|
+
'jobs:',
|
|
280
|
+
' lint:',
|
|
281
|
+
' runs-on: ubuntu-latest',
|
|
282
|
+
' timeout-minutes: 5',
|
|
283
|
+
' concurrency:',
|
|
284
|
+
' group: ${{ github.workflow }}-${{ github.ref }}-lint',
|
|
285
|
+
' cancel-in-progress: true',
|
|
286
|
+
' steps:',
|
|
287
|
+
' - uses: actions/checkout@v6',
|
|
288
|
+
' test:',
|
|
289
|
+
' runs-on: ubuntu-latest',
|
|
290
|
+
' timeout-minutes: 5',
|
|
291
|
+
' concurrency:',
|
|
292
|
+
' group: ${{ github.workflow }}-${{ github.ref }}-test',
|
|
293
|
+
' cancel-in-progress: true',
|
|
294
|
+
` needs: [${needs}]`,
|
|
295
|
+
condition,
|
|
296
|
+
' steps:',
|
|
297
|
+
' - uses: actions/checkout@v6',
|
|
298
|
+
' pipeline-status:',
|
|
299
|
+
' runs-on: ubuntu-latest',
|
|
300
|
+
' timeout-minutes: 5',
|
|
301
|
+
' concurrency:',
|
|
302
|
+
' group: ${{ github.workflow }}-${{ github.ref }}-pipeline-status',
|
|
303
|
+
' cancel-in-progress: true',
|
|
304
|
+
' needs: [lint, test]',
|
|
305
|
+
' steps:',
|
|
306
|
+
' - run: bash scripts/check-pipeline-status.sh',
|
|
307
|
+
'',
|
|
308
|
+
]
|
|
309
|
+
.filter((section) => section !== '')
|
|
310
|
+
.join('\n');
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
describe('fast-fail job ordering', () => {
|
|
314
|
+
it('rejects a test matrix that does not wait for lint', () => {
|
|
315
|
+
assert.equal(countFailures(lintAndTest({ needs: 'detect-changes' })), 1);
|
|
316
|
+
});
|
|
317
|
+
|
|
318
|
+
it('rejects a lint gate neutralised by !cancelled()', () => {
|
|
319
|
+
// `!cancelled()` overrides the implicit "all needs succeeded" rule, so
|
|
320
|
+
// `needs: [lint]` alone lets the matrix run after lint has already failed.
|
|
321
|
+
// The dependency is then decorative, which is worse than not having it:
|
|
322
|
+
// the graph claims a gate the run does not have.
|
|
323
|
+
assert.equal(
|
|
324
|
+
countFailures(
|
|
325
|
+
lintAndTest({
|
|
326
|
+
needs: 'lint',
|
|
327
|
+
condition: " if: >-\n !cancelled() && github.ref != 'x'",
|
|
328
|
+
})
|
|
329
|
+
),
|
|
330
|
+
1
|
|
331
|
+
);
|
|
332
|
+
});
|
|
333
|
+
|
|
334
|
+
it('accepts a lint gate that restates the failure guard', () => {
|
|
335
|
+
assert.equal(
|
|
336
|
+
countFailures(
|
|
337
|
+
lintAndTest({
|
|
338
|
+
needs: 'lint',
|
|
339
|
+
condition:
|
|
340
|
+
" if: >-\n !cancelled() && !contains(needs.*.result, 'failure')",
|
|
341
|
+
})
|
|
342
|
+
),
|
|
343
|
+
0
|
|
344
|
+
);
|
|
345
|
+
});
|
|
346
|
+
|
|
347
|
+
it('accepts a lint gate stated as an explicit result comparison', () => {
|
|
348
|
+
assert.equal(
|
|
349
|
+
countFailures(
|
|
350
|
+
lintAndTest({
|
|
351
|
+
needs: 'lint',
|
|
352
|
+
condition:
|
|
353
|
+
" if: >-\n !cancelled() && needs.lint.result == 'success'",
|
|
354
|
+
})
|
|
355
|
+
),
|
|
356
|
+
0
|
|
357
|
+
);
|
|
358
|
+
});
|
|
359
|
+
|
|
360
|
+
it('accepts a plain dependency with no status function at all', () => {
|
|
361
|
+
// Without `always()` or `!cancelled()`, GitHub's own default already
|
|
362
|
+
// refuses to start the job when lint fails; nothing has to be restated.
|
|
363
|
+
assert.equal(countFailures(lintAndTest({ needs: 'lint' })), 0);
|
|
225
364
|
});
|
|
226
365
|
});
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Guard for the second half of the errexit change.
|
|
3
|
+
*
|
|
4
|
+
* Turning `errexit` on in loadCommandStream() makes a failed command reject —
|
|
5
|
+
* but `command-stream`'s rejection carries only
|
|
6
|
+
*
|
|
7
|
+
* message : "Command failed with exit code 101"
|
|
8
|
+
* code : 101
|
|
9
|
+
* stdout : ""
|
|
10
|
+
* stderr : "error: crate version 0.10.11 is already uploaded\n"
|
|
11
|
+
*
|
|
12
|
+
* The diagnostic text a caller needs to branch on is in `stderr`, *not* in
|
|
13
|
+
* `message`. Three release scripts were written as
|
|
14
|
+
* `error.message.includes('already exists')`, which silently stops matching the
|
|
15
|
+
* moment the promise actually rejects. Left unfixed, enabling errexit would
|
|
16
|
+
* convert a harmless re-run over an already-published version into a red
|
|
17
|
+
* release job.
|
|
18
|
+
*
|
|
19
|
+
* `commandErrorText()` is the shared accessor those call sites use instead.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
import assert from 'node:assert/strict';
|
|
23
|
+
import { describe, it } from 'node:test';
|
|
24
|
+
|
|
25
|
+
import { commandErrorText } from '../../../../scripts/use-module.mjs';
|
|
26
|
+
|
|
27
|
+
describe('commandErrorText', () => {
|
|
28
|
+
it('finds text that command-stream puts in stderr rather than message', () => {
|
|
29
|
+
const error = Object.assign(
|
|
30
|
+
new Error('Command failed with exit code 101'),
|
|
31
|
+
{
|
|
32
|
+
code: 101,
|
|
33
|
+
stdout: '',
|
|
34
|
+
stderr: 'error: crate version 0.10.11 is already uploaded\n',
|
|
35
|
+
}
|
|
36
|
+
);
|
|
37
|
+
|
|
38
|
+
assert.equal(
|
|
39
|
+
error.message.includes('already uploaded'),
|
|
40
|
+
false,
|
|
41
|
+
'precondition: the message alone does not carry the reason'
|
|
42
|
+
);
|
|
43
|
+
assert.ok(commandErrorText(error).includes('already uploaded'));
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it('includes stdout, which is where gh reports an existing release', () => {
|
|
47
|
+
const error = Object.assign(new Error('Command failed with exit code 1'), {
|
|
48
|
+
code: 1,
|
|
49
|
+
stdout: '{"message":"Reference already exists"}',
|
|
50
|
+
stderr: '',
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
assert.ok(commandErrorText(error).includes('already exists'));
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it('keeps the message so an unrecognised failure is still describable', () => {
|
|
57
|
+
const error = Object.assign(new Error('Command failed with exit code 7'), {
|
|
58
|
+
code: 7,
|
|
59
|
+
stdout: '',
|
|
60
|
+
stderr: '',
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
assert.match(commandErrorText(error), /exit code 7/);
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it('tolerates Buffer stdio, which is what capture returns without an encoding', () => {
|
|
67
|
+
const error = Object.assign(new Error('Command failed with exit code 1'), {
|
|
68
|
+
code: 1,
|
|
69
|
+
stdout: Buffer.from('already exists'),
|
|
70
|
+
stderr: Buffer.from(''),
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
assert.ok(commandErrorText(error).includes('already exists'));
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it('does not throw on a non-command error, or on null', () => {
|
|
77
|
+
assert.equal(commandErrorText(new Error('plain')), 'plain');
|
|
78
|
+
assert.equal(commandErrorText(null), '');
|
|
79
|
+
assert.equal(commandErrorText(undefined), '');
|
|
80
|
+
// A thrown string is not an Error but must still be describable.
|
|
81
|
+
assert.equal(commandErrorText('boom'), 'boom');
|
|
82
|
+
});
|
|
83
|
+
});
|