browser-commander 0.25.0 → 0.26.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.
Files changed (52) hide show
  1. package/.jscpd-baseline.json +6 -4
  2. package/CHANGELOG.md +32 -0
  3. package/package.json +2 -2
  4. package/scripts/check-duplication.mjs +176 -0
  5. package/scripts/create-manual-changeset.mjs +3 -1
  6. package/scripts/format-release-notes.mjs +8 -3
  7. package/scripts/merge-changesets.mjs +4 -1
  8. package/scripts/validate-changeset.mjs +23 -10
  9. package/scripts/version-and-commit.mjs +8 -10
  10. package/src/browser/browser-sources.json +10 -2
  11. package/src/browser/launcher.js +14 -0
  12. package/src/browser/real-browser.js +5 -0
  13. package/src/browser/safari-launch.js +42 -0
  14. package/src/browser/safari-support.js +98 -0
  15. package/src/browser/storage-state.js +28 -8
  16. package/src/browser/webdriver.js +132 -21
  17. package/src/cli/sessions.js +5 -0
  18. package/src/core/webdriver-page.js +62 -0
  19. package/src/elements/selectors.js +31 -14
  20. package/src/exports.js +6 -0
  21. package/src/parity/harness.js +4 -1
  22. package/src/tests/index.js +12 -1
  23. package/src/traces/recorder.js +5 -0
  24. package/tests/e2e/engine-matrix.e2e.test.js +2 -1
  25. package/tests/e2e/safari.e2e.test.js +105 -0
  26. package/tests/e2e/webdriver.e2e.test.js +2 -1
  27. package/tests/helpers/fixture-server.js +18 -1
  28. package/tests/unit/browser/real-browser.test.js +1 -1
  29. package/tests/unit/browser/safari.test.js +192 -0
  30. package/tests/unit/browser/webdriver.test.js +148 -3
  31. package/tests/unit/cli/dispatcher.test.js +1 -1
  32. package/tests/unit/elements/selectors.test.js +75 -0
  33. package/tests/unit/helpers/fixture-server.test.js +60 -0
  34. package/tests/unit/playwright-protocol-coverage.test.js +9 -6
  35. package/tests/unit/puppeteer-bindings-coverage.test.js +28 -7
  36. package/tests/unit/scripts/bot-commit-attribution.test.js +70 -0
  37. package/tests/unit/scripts/check-duplication.test.js +139 -0
  38. package/tests/unit/scripts/check-pipeline-status.test.js +264 -52
  39. package/tests/unit/scripts/check-version-modification.test.js +107 -1
  40. package/tests/unit/scripts/ci-timeout-budgets.test.js +15 -2
  41. package/tests/unit/scripts/ci-workflow-policy.test.js +91 -7
  42. package/tests/unit/scripts/command-stream-errexit.test.js +83 -41
  43. package/tests/unit/scripts/e2e-suite-coverage.test.js +174 -0
  44. package/tests/unit/scripts/github-actions-log.test.js +99 -0
  45. package/tests/unit/scripts/links-workflow.test.js +42 -0
  46. package/tests/unit/scripts/preflight-credentials.test.js +527 -0
  47. package/tests/unit/scripts/run-with-budget-warning.test.js +212 -6
  48. package/tests/unit/scripts/use-module-integration.test.js +2 -9
  49. package/tests/unit/scripts/use-module.test.js +20 -0
  50. package/tests/unit/scripts/validate-changeset.test.js +127 -0
  51. package/tests/unit/scripts/zizmor-version.test.js +82 -0
  52. package/tests/unit/tests/index.test.js +43 -0
@@ -0,0 +1,99 @@
1
+ /**
2
+ * Contributor-authored text must not become a workflow command.
3
+ *
4
+ * GitHub Actions reads any log line that starts with `::name::` or `##[name]`
5
+ * as a command, so a changeset that quotes `::error::` printed bare raises a
6
+ * fake annotation — and `::add-mask::`, `::stop-commands::` or `::debug::` can
7
+ * change what the rest of the log shows. Issue #128 adopts the
8
+ * link-foundation templates' guard (rust template issue #173).
9
+ */
10
+
11
+ import assert from 'node:assert/strict';
12
+ import { readFileSync } from 'node:fs';
13
+ import { describe, it } from 'node:test';
14
+
15
+ import { printUntrusted } from '../../../../scripts/github-actions-log.mjs';
16
+ import { repoPath } from '../../helpers/repo.js';
17
+
18
+ function capturePrint(value, options = {}) {
19
+ let output = '';
20
+ printUntrusted(value, {
21
+ stream: { write: (chunk) => (output += chunk) },
22
+ ...options,
23
+ });
24
+ return output;
25
+ }
26
+
27
+ describe('github-actions-log printUntrusted', () => {
28
+ it('prints local output unchanged', () => {
29
+ assert.equal(
30
+ capturePrint('quoted ::error:: text', { githubActions: false }),
31
+ 'quoted ::error:: text\n'
32
+ );
33
+ });
34
+
35
+ it('brackets the whole value while GitHub interprets commands', () => {
36
+ const output = capturePrint('first\n::error::not a real annotation', {
37
+ githubActions: true,
38
+ tokenFactory: () => '0123456789abcdef0123456789abcdef',
39
+ });
40
+
41
+ assert.equal(
42
+ output,
43
+ [
44
+ '::stop-commands::0123456789abcdef0123456789abcdef',
45
+ 'first',
46
+ '::error::not a real annotation',
47
+ '::0123456789abcdef0123456789abcdef::',
48
+ '',
49
+ ].join('\n')
50
+ );
51
+ });
52
+
53
+ it('uses a fresh 128-bit token for every print', () => {
54
+ const tokenPattern = /^::stop-commands::([0-9a-f]{32})$/m;
55
+ const first = capturePrint('a', { githubActions: true });
56
+ const second = capturePrint('b', { githubActions: true });
57
+ const firstToken = first.match(tokenPattern)?.[1];
58
+ const secondToken = second.match(tokenPattern)?.[1];
59
+
60
+ assert.ok(firstToken && secondToken);
61
+ assert.notEqual(firstToken, secondToken);
62
+ assert.ok(first.endsWith(`::${firstToken}::\n`));
63
+ });
64
+
65
+ it('follows GITHUB_ACTIONS when not told otherwise', () => {
66
+ const saved = process.env.GITHUB_ACTIONS;
67
+
68
+ try {
69
+ process.env.GITHUB_ACTIONS = 'true';
70
+ assert.match(capturePrint('x'), /^::stop-commands::/);
71
+ process.env.GITHUB_ACTIONS = '';
72
+ assert.equal(capturePrint('x'), 'x\n');
73
+ } finally {
74
+ if (saved === undefined) {
75
+ delete process.env.GITHUB_ACTIONS;
76
+ } else {
77
+ process.env.GITHUB_ACTIONS = saved;
78
+ }
79
+ }
80
+ });
81
+
82
+ it('guards every script that prints a changeset or release description', () => {
83
+ for (const file of [
84
+ 'js/scripts/validate-changeset.mjs',
85
+ 'js/scripts/merge-changesets.mjs',
86
+ 'js/scripts/create-manual-changeset.mjs',
87
+ 'js/scripts/version-and-commit.mjs',
88
+ ]) {
89
+ const source = readFileSync(repoPath(file), 'utf8');
90
+
91
+ assert.match(
92
+ source,
93
+ /from '\.\.\/\.\.\/scripts\/github-actions-log\.mjs'/,
94
+ file
95
+ );
96
+ assert.match(source, /printUntrusted\(/, file);
97
+ }
98
+ });
99
+ });
@@ -0,0 +1,42 @@
1
+ /**
2
+ * links.yml runs lychee with `--cache`, which reads and writes `.lycheecache`
3
+ * in the workspace. Every job starts from a fresh checkout, so without a step
4
+ * that restores and saves that file the flag does nothing: each run checked
5
+ * every link from scratch, while the job comment promised "<1min with the
6
+ * lychee cache" (issue #128). The lychee-action README pairs the flag with
7
+ * actions/cache for this reason.
8
+ */
9
+
10
+ import assert from 'node:assert/strict';
11
+ import { readFileSync } from 'node:fs';
12
+ import { describe, it } from 'node:test';
13
+
14
+ import { repoPath } from '../../helpers/repo.js';
15
+
16
+ const workflow = readFileSync(
17
+ repoPath('.github/workflows/links.yml'),
18
+ 'utf8'
19
+ ).replaceAll('\r\n', '\n');
20
+
21
+ describe('links.yml lychee cache', () => {
22
+ const lycheeStep = workflow.indexOf('uses: lycheeverse/lychee-action@');
23
+ const usesCacheFlag = /^\s+--cache\s*$/m.test(workflow);
24
+
25
+ it('runs lychee', () => {
26
+ assert.ok(lycheeStep > 0, 'lychee step not found');
27
+ });
28
+
29
+ it('restores .lycheecache before lychee when --cache is on', () => {
30
+ if (!usesCacheFlag) {
31
+ return;
32
+ }
33
+ const cacheStep = workflow.search(
34
+ /uses: actions\/cache@\S+\n\s+with:\n\s+path: \.lycheecache\n/
35
+ );
36
+ assert.ok(
37
+ cacheStep > 0,
38
+ '--cache needs an actions/cache step for .lycheecache'
39
+ );
40
+ assert.ok(cacheStep < lycheeStep, 'the cache must be restored first');
41
+ });
42
+ });
@@ -0,0 +1,527 @@
1
+ /**
2
+ * The release preflight: prove the registries will accept the publish before
3
+ * anything is built.
4
+ *
5
+ * Run 37509328334 built, tested and versioned the Python package on main and
6
+ * only then failed at "Publish to PyPI" with `invalid-publisher` (issue #128).
7
+ * `scripts/preflight-credentials.sh` performs the credential half of each
8
+ * publish up front -- the OIDC exchange PyPI and npm trusted publishing do, and
9
+ * the crates.io token check -- and the `release-preflight` job in each
10
+ * language workflow gates the publishing jobs on it. These tests pin the rules
11
+ * the workflows depend on (principle 16 of the link-foundation pipeline
12
+ * templates): every failure is reported, `unknown` is never a pass in release
13
+ * mode, report mode never blocks, and no token is ever printed. Every registry
14
+ * is a local HTTP stub, so nothing here talks to the network.
15
+ *
16
+ * See docs/CI-TIMEOUT-BUDGETS.md.
17
+ */
18
+
19
+ import assert from 'node:assert/strict';
20
+ import { spawn } from 'node:child_process';
21
+ import { once } from 'node:events';
22
+ import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
23
+ import { createServer } from 'node:http';
24
+ import { tmpdir } from 'node:os';
25
+ import path from 'node:path';
26
+ import { after, before, beforeEach, describe, it } from 'node:test';
27
+
28
+ import { BASH_AVAILABLE, readRepoText, repoPath } from '../../helpers/repo.js';
29
+
30
+ const SCRIPT = repoPath('scripts/preflight-credentials.sh');
31
+
32
+ // Values a leak would make visible: none of them may reach the output.
33
+ const REQUEST_TOKEN = 'gh-request-token-must-not-leak';
34
+ const OIDC_TOKEN = 'oidc-jwt-must-not-leak';
35
+ const ISSUED_TOKEN = 'registry-token-must-not-leak';
36
+ const CARGO_SECRET = 'cio-cargo-token-must-not-leak';
37
+
38
+ const PENDING_PUBLISHER_URL = 'https://pypi.org/manage/account/publishing/';
39
+
40
+ /**
41
+ * One HTTP server standing in for GitHub's OIDC endpoint and all three
42
+ * registries. `routes` maps "METHOD /path" to [status, body]; every request is
43
+ * recorded so a test can check what the script sent.
44
+ */
45
+ const stub = { routes: {}, requests: [], origin: '', server: null };
46
+
47
+ function respond(request, response) {
48
+ let body = '';
49
+ request.on('data', (chunk) => {
50
+ body += chunk;
51
+ });
52
+ request.on('end', () => {
53
+ const [pathname] = request.url.split('?');
54
+ const route = `${request.method} ${pathname}`;
55
+ stub.requests.push({
56
+ route,
57
+ url: request.url,
58
+ authorization: request.headers.authorization ?? '',
59
+ userAgent: request.headers['user-agent'] ?? '',
60
+ body,
61
+ });
62
+ const [status, payload] = stub.routes[route] ?? [418, { error: route }];
63
+ response.writeHead(status, { 'Content-Type': 'application/json' });
64
+ response.end(
65
+ typeof payload === 'string' ? payload : JSON.stringify(payload)
66
+ );
67
+ });
68
+ }
69
+
70
+ /** A port nothing listens on: bound once, then released. */
71
+ async function closedPort() {
72
+ const probe = createServer();
73
+ probe.listen(0, '127.0.0.1');
74
+ await once(probe, 'listening');
75
+ const { port } = probe.address();
76
+ probe.close();
77
+ await once(probe, 'close');
78
+ return port;
79
+ }
80
+
81
+ let workDir;
82
+
83
+ function environment(overrides) {
84
+ return {
85
+ ...process.env,
86
+ ACTIONS_ID_TOKEN_REQUEST_URL: `${stub.origin}/oidc?api-version=2.0`,
87
+ ACTIONS_ID_TOKEN_REQUEST_TOKEN: REQUEST_TOKEN,
88
+ PYPI_API: `${stub.origin}/pypi`,
89
+ NPM_REGISTRY: `${stub.origin}/npm`,
90
+ CRATES_API: `${stub.origin}/crates`,
91
+ NPM_PACKAGE: '@link-foundation/probe',
92
+ CARGO_REGISTRY_TOKEN: '',
93
+ CARGO_TOKEN: '',
94
+ GITHUB_STEP_SUMMARY: '',
95
+ PREFLIGHT_CURL_TIMEOUT: '5',
96
+ PREFLIGHT_MODE: 'release',
97
+ ...overrides,
98
+ };
99
+ }
100
+
101
+ /**
102
+ * Run the script asynchronously: the stub lives in this process, so a
103
+ * synchronous spawn would block the event loop that has to answer it.
104
+ */
105
+ async function preflight(overrides = {}, cwd = workDir) {
106
+ const child = spawn('bash', [SCRIPT], { cwd, env: environment(overrides) });
107
+ let output = '';
108
+ child.stdout.on('data', (chunk) => {
109
+ output += chunk;
110
+ });
111
+ child.stderr.on('data', (chunk) => {
112
+ output += chunk;
113
+ });
114
+ const [status] = await once(child, 'close');
115
+ return { status, output };
116
+ }
117
+
118
+ function assertNoSecrets(output) {
119
+ for (const secret of [
120
+ REQUEST_TOKEN,
121
+ OIDC_TOKEN,
122
+ ISSUED_TOKEN,
123
+ CARGO_SECRET,
124
+ ]) {
125
+ assert.ok(!output.includes(secret), `output leaked ${secret}:\n${output}`);
126
+ }
127
+ }
128
+
129
+ const OIDC_OK = [200, { value: OIDC_TOKEN }];
130
+ const MINT = 'POST /pypi/_/oidc/mint-token';
131
+ const EXCHANGE =
132
+ 'POST /npm/-/npm/v1/oidc/token/exchange/package/@link-foundation%2Fprobe';
133
+ const ME = 'GET /crates/api/v1/me';
134
+ const INVALID_PUBLISHER = [
135
+ 422,
136
+ {
137
+ message: 'Token request failed',
138
+ errors: [
139
+ {
140
+ code: 'invalid-publisher',
141
+ description: 'valid token, but no corresponding publisher',
142
+ },
143
+ ],
144
+ },
145
+ ];
146
+
147
+ /** Run the PyPI probe against a mint-token endpoint answering `answer`. */
148
+ function probePypi(answer, overrides = {}) {
149
+ stub.routes[MINT] = answer;
150
+ return preflight({ PREFLIGHT_REGISTRIES: 'pypi', ...overrides });
151
+ }
152
+
153
+ describe('scripts/preflight-credentials.sh', { skip: !BASH_AVAILABLE }, () => {
154
+ before(async () => {
155
+ stub.server = createServer(respond);
156
+ stub.server.listen(0, '127.0.0.1');
157
+ await once(stub.server, 'listening');
158
+ stub.origin = `http://127.0.0.1:${stub.server.address().port}`;
159
+ workDir = mkdtempSync(path.join(tmpdir(), 'preflight-'));
160
+ });
161
+
162
+ after(async () => {
163
+ stub.server.close();
164
+ await once(stub.server, 'close');
165
+ rmSync(workDir, { recursive: true, force: true });
166
+ });
167
+
168
+ beforeEach(() => {
169
+ stub.routes = { 'GET /oidc': OIDC_OK };
170
+ stub.requests = [];
171
+ rmSync(path.join(workDir, 'Cargo.toml'), { force: true });
172
+ rmSync(path.join(workDir, 'package.json'), { force: true });
173
+ });
174
+
175
+ describe('PyPI trusted publishing', () => {
176
+ it('passes when PyPI mints an upload token, without printing it', async () => {
177
+ const { status, output } = await probePypi([
178
+ 200,
179
+ { success: true, token: ISSUED_TOKEN },
180
+ ]);
181
+
182
+ assert.equal(status, 0, output);
183
+ assert.match(output, /PASS: PyPI minted a short-lived upload token/);
184
+ assert.match(output, /1 verified, 0 failed, 0 unknown/);
185
+ assertNoSecrets(output);
186
+
187
+ const [oidc, mint] = stub.requests;
188
+ assert.equal(oidc.url, '/oidc?api-version=2.0&audience=pypi');
189
+ assert.equal(oidc.authorization, `Bearer ${REQUEST_TOKEN}`);
190
+ assert.deepEqual(JSON.parse(mint.body), { token: OIDC_TOKEN });
191
+ });
192
+
193
+ it('fails a release on invalid-publisher and points at the registration page', async () => {
194
+ const { status, output } = await probePypi(INVALID_PUBLISHER);
195
+
196
+ assert.equal(status, 1, output);
197
+ assert.match(
198
+ output,
199
+ /::error::release-preflight: PyPI refused to mint an upload token \(422, invalid-publisher\)/
200
+ );
201
+ // Compared whole: a substring check on a URL is what CodeQL's
202
+ // incomplete-url-substring-sanitization rule flags.
203
+ const registration = output.match(/Register one \(.*?\) at (\S+);/);
204
+ assert.equal(registration?.[1], PENDING_PUBLISHER_URL, output);
205
+ assert.match(output, /explain_pypi_failure\.py/);
206
+ assert.match(output, /refusing to release with 1 refused credential/);
207
+ assertNoSecrets(output);
208
+ });
209
+
210
+ it('only warns about invalid-publisher in report mode', async () => {
211
+ const { status, output } = await probePypi(INVALID_PUBLISHER, {
212
+ PREFLIGHT_MODE: 'report',
213
+ });
214
+
215
+ assert.equal(status, 0, output);
216
+ assert.match(output, /::warning::release-preflight: PyPI refused/);
217
+ assert.doesNotMatch(output, /::error::/);
218
+ assert.match(output, /Report mode: the failures above are advisory/);
219
+ });
220
+
221
+ it('reports an unreachable PyPI as unknown, which is not a release pass', async () => {
222
+ const port = await closedPort();
223
+ const env = {
224
+ PREFLIGHT_REGISTRIES: 'pypi',
225
+ PYPI_API: `http://127.0.0.1:${port}`,
226
+ };
227
+
228
+ const release = await preflight(env);
229
+ assert.equal(release.status, 1, release.output);
230
+ assert.match(
231
+ release.output,
232
+ /UNKNOWN: PyPI was unreachable during the mint-token probe/
233
+ );
234
+ assert.match(release.output, /0 verified, 0 failed, 1 unknown/);
235
+ assert.match(
236
+ release.output,
237
+ /::error::release-preflight: verified nothing \(1 unknown\)/
238
+ );
239
+
240
+ const report = await preflight({ ...env, PREFLIGHT_MODE: 'report' });
241
+ assert.equal(report.status, 0, report.output);
242
+ assert.match(report.output, /Report mode: nothing was verified/);
243
+ assert.doesNotMatch(report.output, /::error::/);
244
+ });
245
+
246
+ it('treats an unexpected PyPI answer as unknown, not as a refusal', async () => {
247
+ const { status, output } = await probePypi([503, 'Service Unavailable']);
248
+
249
+ assert.equal(status, 1, output);
250
+ assert.match(output, /UNKNOWN: PyPI answered 503/);
251
+ assert.match(output, /0 failed, 1 unknown/);
252
+ });
253
+
254
+ it('fails when the job has no OIDC token to exchange', async () => {
255
+ const { status, output } = await preflight({
256
+ PREFLIGHT_REGISTRIES: 'pypi',
257
+ ACTIONS_ID_TOKEN_REQUEST_URL: '',
258
+ ACTIONS_ID_TOKEN_REQUEST_TOKEN: '',
259
+ });
260
+
261
+ assert.equal(status, 1, output);
262
+ assert.match(
263
+ output,
264
+ /::error::release-preflight: PyPI: no OIDC token available: .*id-token: write/
265
+ );
266
+ assert.equal(stub.requests.length, 0);
267
+ });
268
+ });
269
+
270
+ describe('npm trusted publishing', () => {
271
+ it('passes when npm exchanges the OIDC token for the package', async () => {
272
+ stub.routes[EXCHANGE] = [201, { token: ISSUED_TOKEN }];
273
+
274
+ const { status, output } = await preflight({
275
+ PREFLIGHT_REGISTRIES: 'npm',
276
+ });
277
+
278
+ assert.equal(status, 0, output);
279
+ assert.match(output, /PASS: npm exchanged the OIDC token/);
280
+ assertNoSecrets(output);
281
+
282
+ const [oidc, exchange] = stub.requests;
283
+ assert.match(oidc.url, /&audience=npm%3A127\.0\.0\.1:\d+$/);
284
+ assert.equal(exchange.route, EXCHANGE);
285
+ assert.equal(exchange.authorization, `Bearer ${OIDC_TOKEN}`);
286
+ });
287
+
288
+ it('fails a release when npm refuses the exchange', async () => {
289
+ stub.routes[EXCHANGE] = [404, { message: 'no trusted publisher' }];
290
+
291
+ const { status, output } = await preflight({
292
+ PREFLIGHT_REGISTRIES: 'npm',
293
+ });
294
+
295
+ assert.equal(status, 1, output);
296
+ assert.match(
297
+ output,
298
+ /::error::release-preflight: npm refused the OIDC token exchange for @link-foundation\/probe \(404: no trusted publisher\)/
299
+ );
300
+ });
301
+
302
+ it('reads the package name from package.json when NPM_PACKAGE is unset', async () => {
303
+ writeFileSync(
304
+ path.join(workDir, 'package.json'),
305
+ '{\n "name": "browser-commander",\n "version": "1.0.0"\n}\n'
306
+ );
307
+ stub.routes[
308
+ 'POST /npm/-/npm/v1/oidc/token/exchange/package/browser-commander'
309
+ ] = [200, { token: ISSUED_TOKEN }];
310
+
311
+ const { status, output } = await preflight({
312
+ PREFLIGHT_REGISTRIES: 'npm',
313
+ NPM_PACKAGE: '',
314
+ });
315
+
316
+ assert.equal(status, 0, output);
317
+ assert.match(output, /publish token for browser-commander/);
318
+ });
319
+ });
320
+
321
+ describe('crates.io token', () => {
322
+ it('passes a live API token that /api/v1/me refuses by design', async () => {
323
+ stub.routes[ME] = [
324
+ 403,
325
+ {
326
+ errors: [
327
+ {
328
+ detail:
329
+ 'this action can only be performed on the crates.io website',
330
+ },
331
+ ],
332
+ },
333
+ ];
334
+
335
+ const { status, output } = await preflight({
336
+ PREFLIGHT_REGISTRIES: 'crates',
337
+ CARGO_REGISTRY_TOKEN: CARGO_SECRET,
338
+ });
339
+
340
+ assert.equal(status, 0, output);
341
+ assert.match(output, /PASS: crates.io authenticated the publish token/);
342
+ assert.equal(stub.requests[0].authorization, CARGO_SECRET);
343
+ assert.match(stub.requests[0].userAgent, /release-preflight/);
344
+ assertNoSecrets(output);
345
+ });
346
+
347
+ it('fails a release on a token crates.io does not recognise', async () => {
348
+ stub.routes[ME] = [
349
+ 403,
350
+ { errors: [{ detail: 'authentication failed' }] },
351
+ ];
352
+
353
+ const { status, output } = await preflight({
354
+ PREFLIGHT_REGISTRIES: 'crates',
355
+ CARGO_TOKEN: CARGO_SECRET,
356
+ });
357
+
358
+ assert.equal(status, 1, output);
359
+ assert.match(
360
+ output,
361
+ /::error::release-preflight: crates.io rejected the publish token \(403, authentication failed\)/
362
+ );
363
+ assertNoSecrets(output);
364
+ });
365
+
366
+ it('fails when no token is configured', async () => {
367
+ const { status, output } = await preflight({
368
+ PREFLIGHT_REGISTRIES: 'crates',
369
+ });
370
+
371
+ assert.equal(status, 1, output);
372
+ assert.match(output, /crates.io has no publish credential/);
373
+ });
374
+
375
+ it('checks crate ownership when the token resolves to a login', async () => {
376
+ writeFileSync(
377
+ path.join(workDir, 'Cargo.toml'),
378
+ '[package]\nname = "probe-crate"\n\n[dependencies]\nname = "decoy"\n'
379
+ );
380
+ stub.routes[ME] = [200, { user: { login: 'Releaser' } }];
381
+ const owners = 'GET /crates/api/v1/crates/probe-crate/owners';
382
+ const env = {
383
+ PREFLIGHT_REGISTRIES: 'crates',
384
+ CARGO_REGISTRY_TOKEN: CARGO_SECRET,
385
+ };
386
+
387
+ stub.routes[owners] = [200, { users: [{ login: 'releaser' }] }];
388
+ const owner = await preflight(env);
389
+ assert.equal(owner.status, 0, owner.output);
390
+ assert.match(owner.output, /PASS: Releaser is an owner of probe-crate/);
391
+
392
+ stub.routes[owners] = [200, { users: [{ login: 'someone-else' }] }];
393
+ const stranger = await preflight(env);
394
+ assert.equal(stranger.status, 1, stranger.output);
395
+ assert.match(stranger.output, /is not an owner of probe-crate/);
396
+ });
397
+ });
398
+
399
+ describe('reporting', () => {
400
+ it('reports every failure rather than stopping at the first', async () => {
401
+ stub.routes[MINT] = INVALID_PUBLISHER;
402
+ stub.routes[EXCHANGE] = [403, { message: 'forbidden' }];
403
+ stub.routes[ME] = [401, { errors: [{ detail: 'bad token' }] }];
404
+ const summary = path.join(workDir, 'summary.md');
405
+ writeFileSync(summary, '');
406
+
407
+ const { status, output } = await preflight({
408
+ PREFLIGHT_REGISTRIES: 'pypi,npm,crates',
409
+ CARGO_REGISTRY_TOKEN: CARGO_SECRET,
410
+ GITHUB_STEP_SUMMARY: summary,
411
+ });
412
+
413
+ assert.equal(status, 1, output);
414
+ assert.match(output, /0 verified, 3 failed, 0 unknown/);
415
+ assert.equal(output.match(/^::error::release-preflight: /gm).length, 4);
416
+ assert.match(output, /refusing to release with 3 refused credential/);
417
+ assertNoSecrets(output);
418
+
419
+ const written = readFileSync(summary, 'utf8');
420
+ assert.match(
421
+ written,
422
+ /### Release preflight \(release mode: pypi,npm,crates\)/
423
+ );
424
+ assert.match(written, /\| failed \| 3 \|/);
425
+ assertNoSecrets(written);
426
+ });
427
+
428
+ it('refuses a release that names no registry or an unknown one', async () => {
429
+ const empty = await preflight({ PREFLIGHT_REGISTRIES: '' });
430
+ assert.equal(empty.status, 1, empty.output);
431
+ assert.match(empty.output, /PREFLIGHT_REGISTRIES is empty/);
432
+
433
+ const typo = await preflight({ PREFLIGHT_REGISTRIES: 'pipy' });
434
+ assert.equal(typo.status, 1, typo.output);
435
+ assert.match(typo.output, /unknown registry/);
436
+ });
437
+ });
438
+ });
439
+
440
+ /** Each job of a workflow, keyed by id, as the raw text of its block. */
441
+ function workflowJobs(file) {
442
+ const text = readRepoText('.github', 'workflows', file);
443
+ const body = text.slice(text.indexOf('\njobs:\n') + 1);
444
+ const jobs = new Map();
445
+ for (const chunk of body.split(/\n(?= {2}[\w-]+:\s*\n)/)) {
446
+ const id = /^ {2}([\w-]+):\s*\n/.exec(chunk)?.[1];
447
+ if (id) {
448
+ jobs.set(id, chunk);
449
+ }
450
+ }
451
+ return jobs;
452
+ }
453
+
454
+ /** A job's `needs:`, whether written inline or as a block list. */
455
+ function needsOf(job) {
456
+ const inline = /^ {4}needs: *\[([^\]]*)\]/m.exec(job);
457
+ if (inline) {
458
+ return inline[1].split(',').map((need) => need.trim());
459
+ }
460
+ const list = /^ {4}needs: *\n((?: {6}- .*\n?)+)/m.exec(job);
461
+ return list
462
+ ? list[1].split('\n').map((line) => line.replace(/^ *- */, '').trim())
463
+ : [];
464
+ }
465
+
466
+ const PREFLIGHT_WORKFLOWS = [
467
+ {
468
+ file: 'python.yml',
469
+ registry: 'pypi',
470
+ oidc: true,
471
+ publishers: ['auto-release', 'manual-release'],
472
+ },
473
+ {
474
+ file: 'js.yml',
475
+ registry: 'npm',
476
+ oidc: true,
477
+ publishers: ['release', 'instant-release'],
478
+ },
479
+ {
480
+ file: 'rust.yml',
481
+ registry: 'crates',
482
+ oidc: false,
483
+ publishers: ['auto-release', 'manual-release'],
484
+ },
485
+ ];
486
+
487
+ describe('release-preflight in the language workflows', () => {
488
+ for (const { file, registry, oidc, publishers } of PREFLIGHT_WORKFLOWS) {
489
+ describe(file, () => {
490
+ const jobs = workflowJobs(file);
491
+ const preflightJob = jobs.get('release-preflight') ?? '';
492
+
493
+ it(`has a release-preflight job probing ${registry}`, () => {
494
+ assert.ok(preflightJob, `${file} has no release-preflight job`);
495
+ assert.match(preflightJob, /^ {4}timeout-minutes: 5$/m);
496
+ assert.match(preflightJob, /persist-credentials: false/);
497
+ assert.match(
498
+ preflightJob,
499
+ new RegExp(`PREFLIGHT_REGISTRIES: ${registry}$`, 'm')
500
+ );
501
+ assert.match(preflightJob, /\) && 'release' \|\| 'report'/);
502
+ assert.match(
503
+ preflightJob,
504
+ /run: bash \.\.\/scripts\/preflight-credentials\.sh/
505
+ );
506
+ assert.equal(/id-token: write/.test(preflightJob), oidc);
507
+ });
508
+
509
+ for (const publisher of publishers) {
510
+ it(`gates ${publisher} on a successful preflight`, () => {
511
+ const job = jobs.get(publisher) ?? '';
512
+ assert.ok(job, `${file} has no ${publisher} job`);
513
+ assert.ok(needsOf(job).includes('release-preflight'), job);
514
+ assert.match(job, /needs\.release-preflight\.result == 'success'/);
515
+ });
516
+ }
517
+
518
+ it('reports the preflight in pipeline-status', () => {
519
+ assert.ok(
520
+ needsOf(jobs.get('pipeline-status') ?? '').includes(
521
+ 'release-preflight'
522
+ )
523
+ );
524
+ });
525
+ });
526
+ }
527
+ });