browser-commander 0.26.1 → 0.26.2
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/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,11 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.26.2
|
|
4
|
+
|
|
5
|
+
### Patch Changes
|
|
6
|
+
|
|
7
|
+
- e2ef59f: Verify crates.io trusted publishing before releases and mark repository fixture manifests as non-publishable. Document the account setup required for PyPI and crates.io.
|
|
8
|
+
|
|
3
9
|
## 0.26.1
|
|
4
10
|
|
|
5
11
|
### Patch Changes
|
package/package.json
CHANGED
|
@@ -5,8 +5,8 @@
|
|
|
5
5
|
* Run 37509328334 built, tested and versioned the Python package on main and
|
|
6
6
|
* only then failed at "Publish to PyPI" with `invalid-publisher` (issue #128).
|
|
7
7
|
* `scripts/preflight-credentials.sh` performs the credential half of each
|
|
8
|
-
* publish up front -- the OIDC
|
|
9
|
-
*
|
|
8
|
+
* publish up front -- the OIDC exchanges all three registries do -- and the
|
|
9
|
+
* `release-preflight` job in each
|
|
10
10
|
* language workflow gates the publishing jobs on it. These tests pin the rules
|
|
11
11
|
* the workflows depend on (principle 16 of the link-foundation pipeline
|
|
12
12
|
* templates): every failure is reported, `unknown` is never a pass in release
|
|
@@ -28,6 +28,7 @@ import { after, before, beforeEach, describe, it } from 'node:test';
|
|
|
28
28
|
import { BASH_AVAILABLE, readRepoText, repoPath } from '../../helpers/repo.js';
|
|
29
29
|
|
|
30
30
|
const SCRIPT = repoPath('scripts/preflight-credentials.sh');
|
|
31
|
+
const scriptOptions = { skip: !BASH_AVAILABLE, timeout: 30_000 };
|
|
31
32
|
|
|
32
33
|
// Values a leak would make visible: none of them may reach the output.
|
|
33
34
|
const REQUEST_TOKEN = 'gh-request-token-must-not-leak';
|
|
@@ -130,7 +131,8 @@ const OIDC_OK = [200, { value: OIDC_TOKEN }];
|
|
|
130
131
|
const MINT = 'POST /pypi/_/oidc/mint-token';
|
|
131
132
|
const EXCHANGE =
|
|
132
133
|
'POST /npm/-/npm/v1/oidc/token/exchange/package/@link-foundation%2Fprobe';
|
|
133
|
-
const
|
|
134
|
+
const CRATES_MINT = 'POST /crates/api/v1/trusted_publishing/tokens';
|
|
135
|
+
const CRATES_REVOKE = 'DELETE /crates/api/v1/trusted_publishing/tokens';
|
|
134
136
|
const INVALID_PUBLISHER = [
|
|
135
137
|
422,
|
|
136
138
|
{
|
|
@@ -150,7 +152,12 @@ function probePypi(answer, overrides = {}) {
|
|
|
150
152
|
return preflight({ PREFLIGHT_REGISTRIES: 'pypi', ...overrides });
|
|
151
153
|
}
|
|
152
154
|
|
|
153
|
-
|
|
155
|
+
function probeCrates(answer, overrides = {}) {
|
|
156
|
+
stub.routes[CRATES_MINT] = answer;
|
|
157
|
+
return preflight({ PREFLIGHT_REGISTRIES: 'crates', ...overrides });
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
describe('scripts/preflight-credentials.sh', scriptOptions, () => {
|
|
154
161
|
before(async () => {
|
|
155
162
|
stub.server = createServer(respond);
|
|
156
163
|
stub.server.listen(0, '127.0.0.1');
|
|
@@ -318,81 +325,102 @@ describe('scripts/preflight-credentials.sh', { skip: !BASH_AVAILABLE }, () => {
|
|
|
318
325
|
});
|
|
319
326
|
});
|
|
320
327
|
|
|
321
|
-
describe('crates.io
|
|
322
|
-
it('
|
|
323
|
-
stub.routes[
|
|
324
|
-
403,
|
|
325
|
-
{
|
|
326
|
-
errors: [
|
|
327
|
-
{
|
|
328
|
-
detail:
|
|
329
|
-
'this action can only be performed on the crates.io website',
|
|
330
|
-
},
|
|
331
|
-
],
|
|
332
|
-
},
|
|
333
|
-
];
|
|
328
|
+
describe('crates.io trusted publishing', () => {
|
|
329
|
+
it('exchanges OIDC and revokes the temporary token without using a Cargo secret', async () => {
|
|
330
|
+
stub.routes[CRATES_REVOKE] = [204, ''];
|
|
334
331
|
|
|
335
|
-
const { status, output } = await
|
|
336
|
-
|
|
337
|
-
CARGO_REGISTRY_TOKEN: CARGO_SECRET
|
|
338
|
-
|
|
332
|
+
const { status, output } = await probeCrates(
|
|
333
|
+
[200, { token: ISSUED_TOKEN }],
|
|
334
|
+
{ CARGO_REGISTRY_TOKEN: CARGO_SECRET }
|
|
335
|
+
);
|
|
339
336
|
|
|
340
337
|
assert.equal(status, 0, output);
|
|
341
|
-
assert.match(
|
|
342
|
-
|
|
343
|
-
|
|
338
|
+
assert.match(
|
|
339
|
+
output,
|
|
340
|
+
/PASS: crates.io minted a short-lived publish token/
|
|
341
|
+
);
|
|
342
|
+
const [oidc, mint, revoke] = stub.requests;
|
|
343
|
+
assert.match(oidc.url, /&audience=127\.0\.0\.1:\d+\/crates$/);
|
|
344
|
+
assert.deepEqual(JSON.parse(mint.body), { jwt: OIDC_TOKEN });
|
|
345
|
+
assert.equal(mint.authorization, '');
|
|
346
|
+
assert.equal(revoke.route, CRATES_REVOKE);
|
|
347
|
+
assert.equal(revoke.authorization, `Bearer ${ISSUED_TOKEN}`);
|
|
348
|
+
assert.equal(stub.requests.length, 3);
|
|
344
349
|
assertNoSecrets(output);
|
|
345
350
|
});
|
|
346
351
|
|
|
347
|
-
it('fails a release
|
|
348
|
-
|
|
349
|
-
403,
|
|
350
|
-
{
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
const { status, output } = await preflight({
|
|
354
|
-
PREFLIGHT_REGISTRIES: 'crates',
|
|
355
|
-
CARGO_TOKEN: CARGO_SECRET,
|
|
356
|
-
});
|
|
352
|
+
it('fails a release when no trusted publisher matches, even with a legacy secret', async () => {
|
|
353
|
+
const { status, output } = await probeCrates(
|
|
354
|
+
[403, { errors: [{ detail: 'no matching publisher' }] }],
|
|
355
|
+
{ CARGO_TOKEN: CARGO_SECRET }
|
|
356
|
+
);
|
|
357
357
|
|
|
358
358
|
assert.equal(status, 1, output);
|
|
359
359
|
assert.match(
|
|
360
360
|
output,
|
|
361
|
-
/::error::release-preflight: crates.io
|
|
361
|
+
/::error::release-preflight: crates.io refused the OIDC token exchange \(403\)/
|
|
362
362
|
);
|
|
363
|
+
assert.match(output, /rust\.yml/);
|
|
364
|
+
assert.match(output, /docs\/PUBLISHING\.md/);
|
|
365
|
+
assert.equal(stub.requests.length, 2);
|
|
363
366
|
assertNoSecrets(output);
|
|
364
367
|
});
|
|
365
368
|
|
|
366
|
-
it('fails when no
|
|
369
|
+
it('fails when the job has no OIDC permission, with no secret fallback', async () => {
|
|
367
370
|
const { status, output } = await preflight({
|
|
368
371
|
PREFLIGHT_REGISTRIES: 'crates',
|
|
372
|
+
ACTIONS_ID_TOKEN_REQUEST_URL: '',
|
|
373
|
+
ACTIONS_ID_TOKEN_REQUEST_TOKEN: '',
|
|
374
|
+
CARGO_REGISTRY_TOKEN: CARGO_SECRET,
|
|
369
375
|
});
|
|
370
376
|
|
|
371
377
|
assert.equal(status, 1, output);
|
|
372
|
-
assert.match(
|
|
378
|
+
assert.match(
|
|
379
|
+
output,
|
|
380
|
+
/crates.io: no OIDC token available: .*id-token: write/
|
|
381
|
+
);
|
|
382
|
+
assert.equal(stub.requests.length, 0);
|
|
373
383
|
});
|
|
374
384
|
|
|
375
|
-
it('
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
);
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
PREFLIGHT_REGISTRIES: 'crates',
|
|
384
|
-
CARGO_REGISTRY_TOKEN: CARGO_SECRET,
|
|
385
|
-
};
|
|
385
|
+
it('keeps missing publishers advisory on pull requests', async () => {
|
|
386
|
+
const { status, output } = await probeCrates([403, {}], {
|
|
387
|
+
PREFLIGHT_MODE: 'report',
|
|
388
|
+
});
|
|
389
|
+
assert.equal(status, 0, output);
|
|
390
|
+
assert.match(output, /::warning::release-preflight: crates.io refused/);
|
|
391
|
+
assert.doesNotMatch(output, /::error::/);
|
|
392
|
+
});
|
|
386
393
|
|
|
387
|
-
|
|
388
|
-
const
|
|
389
|
-
|
|
390
|
-
|
|
394
|
+
it('treats missing tokens and unavailable registries as unknown', async () => {
|
|
395
|
+
for (const answer of [
|
|
396
|
+
[200, {}],
|
|
397
|
+
[429, {}],
|
|
398
|
+
[503, {}],
|
|
399
|
+
]) {
|
|
400
|
+
const { status, output } = await probeCrates(answer);
|
|
401
|
+
assert.equal(status, 1, output);
|
|
402
|
+
assert.match(output, /0 verified, 0 failed, 1 unknown/);
|
|
403
|
+
assert.equal(stub.requests.at(-1).route, CRATES_MINT);
|
|
404
|
+
assertNoSecrets(output);
|
|
405
|
+
}
|
|
406
|
+
const port = await closedPort();
|
|
407
|
+
const { status, output } = await preflight({
|
|
408
|
+
PREFLIGHT_REGISTRIES: 'crates',
|
|
409
|
+
CRATES_API: `http://127.0.0.1:${port}`,
|
|
410
|
+
});
|
|
411
|
+
assert.equal(status, 1, output);
|
|
412
|
+
assert.match(output, /UNKNOWN: crates.io was unreachable/);
|
|
413
|
+
});
|
|
391
414
|
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
415
|
+
it('reports failed revocation without exposing the token', async () => {
|
|
416
|
+
stub.routes[CRATES_REVOKE] = [403, { token: ISSUED_TOKEN }];
|
|
417
|
+
const { status, output } = await probeCrates([
|
|
418
|
+
200,
|
|
419
|
+
{ token: ISSUED_TOKEN },
|
|
420
|
+
]);
|
|
421
|
+
assert.equal(status, 1, output);
|
|
422
|
+
assert.match(output, /could not revoke the preflight token/);
|
|
423
|
+
assertNoSecrets(output);
|
|
396
424
|
});
|
|
397
425
|
});
|
|
398
426
|
|
|
@@ -400,7 +428,7 @@ describe('scripts/preflight-credentials.sh', { skip: !BASH_AVAILABLE }, () => {
|
|
|
400
428
|
it('reports every failure rather than stopping at the first', async () => {
|
|
401
429
|
stub.routes[MINT] = INVALID_PUBLISHER;
|
|
402
430
|
stub.routes[EXCHANGE] = [403, { message: 'forbidden' }];
|
|
403
|
-
stub.routes[
|
|
431
|
+
stub.routes[CRATES_MINT] = [401, { errors: [{ detail: 'bad token' }] }];
|
|
404
432
|
const summary = path.join(workDir, 'summary.md');
|
|
405
433
|
writeFileSync(summary, '');
|
|
406
434
|
|
|
@@ -479,7 +507,7 @@ const PREFLIGHT_WORKFLOWS = [
|
|
|
479
507
|
{
|
|
480
508
|
file: 'rust.yml',
|
|
481
509
|
registry: 'crates',
|
|
482
|
-
oidc:
|
|
510
|
+
oidc: true,
|
|
483
511
|
publishers: ['auto-release', 'manual-release'],
|
|
484
512
|
},
|
|
485
513
|
];
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import assert from 'node:assert/strict';
|
|
2
|
+
import { describe, it } from 'node:test';
|
|
3
|
+
import { parse } from 'yaml';
|
|
4
|
+
|
|
5
|
+
import { readRepoText } from '../../helpers/repo.js';
|
|
6
|
+
|
|
7
|
+
describe('trusted publishing configuration', () => {
|
|
8
|
+
const rust = parse(readRepoText('.github/workflows/rust.yml'));
|
|
9
|
+
|
|
10
|
+
for (const jobId of ['auto-release', 'manual-release']) {
|
|
11
|
+
it(`${jobId} uses a fresh OIDC token only when publishing`, () => {
|
|
12
|
+
const job = rust.jobs[jobId];
|
|
13
|
+
assert.equal(job.permissions['id-token'], 'write');
|
|
14
|
+
const auth = job.steps.find((step) => step.id === 'crates_auth');
|
|
15
|
+
const publish = job.steps.find((step) => step.id === 'publish-crate');
|
|
16
|
+
assert.ok(auth, 'missing crates.io authentication step');
|
|
17
|
+
assert.match(
|
|
18
|
+
auth.uses,
|
|
19
|
+
/^rust-lang\/crates-io-auth-action@[a-f0-9]{40}$/
|
|
20
|
+
);
|
|
21
|
+
assert.equal(auth.if, publish.if);
|
|
22
|
+
assert.equal(
|
|
23
|
+
publish.env.CARGO_REGISTRY_TOKEN,
|
|
24
|
+
'${{ steps.crates_auth.outputs.token }}'
|
|
25
|
+
);
|
|
26
|
+
assert.ok(job.steps.indexOf(auth) < job.steps.indexOf(publish));
|
|
27
|
+
assert.ok(
|
|
28
|
+
job.steps.indexOf(auth) >
|
|
29
|
+
job.steps.findIndex((step) => step.name === 'Build release')
|
|
30
|
+
);
|
|
31
|
+
});
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
it('never reads the long-lived Cargo secret in the Rust workflow', () => {
|
|
35
|
+
assert.doesNotMatch(
|
|
36
|
+
readRepoText('.github/workflows/rust.yml'),
|
|
37
|
+
/secrets\.CARGO_TOKEN/
|
|
38
|
+
);
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it('keeps the registered PyPI identity identical across preflight and releases', () => {
|
|
42
|
+
const python = parse(readRepoText('.github/workflows/python.yml'));
|
|
43
|
+
for (const jobId of [
|
|
44
|
+
'release-preflight',
|
|
45
|
+
'auto-release',
|
|
46
|
+
'manual-release',
|
|
47
|
+
]) {
|
|
48
|
+
assert.equal(python.jobs[jobId].permissions['id-token'], 'write');
|
|
49
|
+
assert.equal(python.jobs[jobId].environment, undefined);
|
|
50
|
+
}
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it('marks the template snapshot as private without hiding the real npm package', () => {
|
|
54
|
+
assert.equal(
|
|
55
|
+
JSON.parse(
|
|
56
|
+
readRepoText(
|
|
57
|
+
'docs/case-studies/issue-55/template-snapshots/js/package.json'
|
|
58
|
+
)
|
|
59
|
+
).private,
|
|
60
|
+
true
|
|
61
|
+
);
|
|
62
|
+
assert.notEqual(JSON.parse(readRepoText('js/package.json')).private, true);
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it('disables publishing for the cookie equivalence experiment only', () => {
|
|
66
|
+
const fixture = readRepoText(
|
|
67
|
+
'experiments/issue-128/cookie-fixture-equivalence/Cargo.toml'
|
|
68
|
+
);
|
|
69
|
+
const packageTable = fixture
|
|
70
|
+
.split(/^\[package\]\s*$/m)[1]
|
|
71
|
+
?.split(/^\[/m)[0];
|
|
72
|
+
assert.match(packageTable ?? '', /^publish\s*=\s*false\s*$/m);
|
|
73
|
+
assert.doesNotMatch(
|
|
74
|
+
readRepoText('rust/Cargo.toml'),
|
|
75
|
+
/^publish\s*=\s*false\s*$/m
|
|
76
|
+
);
|
|
77
|
+
});
|
|
78
|
+
});
|