browser-commander 0.26.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 (39) hide show
  1. package/.jscpd-baseline.json +6 -4
  2. package/CHANGELOG.md +24 -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/webdriver.js +69 -3
  11. package/src/elements/selectors.js +31 -14
  12. package/src/parity/harness.js +4 -1
  13. package/src/tests/index.js +12 -1
  14. package/tests/e2e/engine-matrix.e2e.test.js +2 -1
  15. package/tests/e2e/safari.e2e.test.js +2 -1
  16. package/tests/e2e/webdriver.e2e.test.js +2 -1
  17. package/tests/helpers/fixture-server.js +18 -1
  18. package/tests/unit/browser/webdriver.test.js +100 -0
  19. package/tests/unit/elements/selectors.test.js +75 -0
  20. package/tests/unit/helpers/fixture-server.test.js +60 -0
  21. package/tests/unit/playwright-protocol-coverage.test.js +9 -6
  22. package/tests/unit/puppeteer-bindings-coverage.test.js +28 -7
  23. package/tests/unit/scripts/bot-commit-attribution.test.js +70 -0
  24. package/tests/unit/scripts/check-duplication.test.js +139 -0
  25. package/tests/unit/scripts/check-pipeline-status.test.js +264 -52
  26. package/tests/unit/scripts/check-version-modification.test.js +107 -1
  27. package/tests/unit/scripts/ci-timeout-budgets.test.js +15 -2
  28. package/tests/unit/scripts/ci-workflow-policy.test.js +91 -7
  29. package/tests/unit/scripts/command-stream-errexit.test.js +83 -41
  30. package/tests/unit/scripts/e2e-suite-coverage.test.js +174 -0
  31. package/tests/unit/scripts/github-actions-log.test.js +99 -0
  32. package/tests/unit/scripts/links-workflow.test.js +42 -0
  33. package/tests/unit/scripts/preflight-credentials.test.js +527 -0
  34. package/tests/unit/scripts/run-with-budget-warning.test.js +212 -6
  35. package/tests/unit/scripts/use-module-integration.test.js +2 -9
  36. package/tests/unit/scripts/use-module.test.js +20 -0
  37. package/tests/unit/scripts/validate-changeset.test.js +127 -0
  38. package/tests/unit/scripts/zizmor-version.test.js +82 -0
  39. package/tests/unit/tests/index.test.js +43 -0
@@ -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
+ });