browser-commander 0.17.0 → 0.17.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/.changeset/config.json +1 -0
- package/.jscpd-baseline.json +12 -4
- package/CHANGELOG.md +26 -1
- package/package.json +1 -1
- package/scripts/check-release-format.mjs +80 -0
- package/scripts/create-github-release.mjs +27 -6
- package/scripts/version-and-commit.mjs +25 -2
- 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-release-format.test.js +134 -0
- package/tests/unit/scripts/ci-workflow-policy.test.js +103 -0
- 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/pre-commit-config.test.js +5 -0
- package/tests/unit/scripts/push-failure-classifier.test.js +153 -0
- package/tests/unit/scripts/push-with-rebase-retry.test.js +302 -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/rust-changelog.test.js +147 -0
- package/.changeset/merged-loud-river.md +0 -7
- package/deno.json +0 -7
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Guard for RC-G: `changeset version` aborting on a formatter that is not
|
|
3
|
+
* installed.
|
|
4
|
+
*
|
|
5
|
+
* @changesets/cli v3 formats the changelog it writes. With the default
|
|
6
|
+
* `"format": "auto"`, @changesets/format walks `defaultDetectOrder` --
|
|
7
|
+
* dprint, deno, oxfmt, biome, prettier -- and picks the first formatter whose
|
|
8
|
+
* config file it finds. `js/deno.json` existed -- orphaned configuration no
|
|
9
|
+
* workflow ever used -- so deno won over the prettier this package actually
|
|
10
|
+
* depends on. deno is the one entry in that table with
|
|
11
|
+
* no `packageName`, so it is spawned straight off PATH with no existence
|
|
12
|
+
* check and no npx fallback; GitHub's ubuntu-latest runner has no deno.
|
|
13
|
+
*
|
|
14
|
+
* `changeset version` therefore bumped package.json, wrote CHANGELOG.md, and
|
|
15
|
+
* then died with `spawn deno ENOENT` before deleting the changeset it had
|
|
16
|
+
* just consumed. The release still shipped -- 0.17.0 is on npm -- but
|
|
17
|
+
* `merged-loud-river.md` survived to be released a second time, and the
|
|
18
|
+
* half-written CHANGELOG.md kept trailing whitespace that fails
|
|
19
|
+
* `prettier --check`.
|
|
20
|
+
*
|
|
21
|
+
* Reproduction: experiments/ci-repro/repro-changeset-deno-formatter.mjs
|
|
22
|
+
* Analysis: dev/log/issues/83/pulls/84/analysis/root-causes.md, RC-G
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
import assert from 'node:assert/strict';
|
|
26
|
+
import { existsSync, readFileSync, readdirSync } from 'node:fs';
|
|
27
|
+
import { createRequire } from 'node:module';
|
|
28
|
+
import { dirname, join } from 'node:path';
|
|
29
|
+
import { fileURLToPath } from 'node:url';
|
|
30
|
+
import { describe, it } from 'node:test';
|
|
31
|
+
|
|
32
|
+
const packageRoot = join(dirname(fileURLToPath(import.meta.url)), '../../..');
|
|
33
|
+
const require = createRequire(import.meta.url);
|
|
34
|
+
|
|
35
|
+
const config = JSON.parse(
|
|
36
|
+
readFileSync(join(packageRoot, '.changeset/config.json'), 'utf-8')
|
|
37
|
+
);
|
|
38
|
+
|
|
39
|
+
describe('changesets formatter', () => {
|
|
40
|
+
it('pins a formatter instead of auto-detecting one', () => {
|
|
41
|
+
assert.notEqual(
|
|
42
|
+
config.format,
|
|
43
|
+
undefined,
|
|
44
|
+
'omitting `format` means "auto", which detects whichever formatter ' +
|
|
45
|
+
'config happens to sit beside package.json and then fails on a ' +
|
|
46
|
+
'runner that has no such binary'
|
|
47
|
+
);
|
|
48
|
+
assert.notEqual(config.format, 'auto', 'same as omitting it');
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it('pins a formatter this package can actually run', () => {
|
|
52
|
+
// `false` disables formatting, which is also a safe answer.
|
|
53
|
+
if (config.format === false) {
|
|
54
|
+
return;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const { defaultDetectOrder } = require('@changesets/format');
|
|
58
|
+
assert.ok(
|
|
59
|
+
defaultDetectOrder.includes(config.format),
|
|
60
|
+
`unknown formatter ${JSON.stringify(config.format)}; expected one of ${defaultDetectOrder.join(
|
|
61
|
+
', '
|
|
62
|
+
)}`
|
|
63
|
+
);
|
|
64
|
+
|
|
65
|
+
// @changesets/format keeps the formatter table private, so mirror the
|
|
66
|
+
// one fact this test needs: which formatters it resolves through
|
|
67
|
+
// node_modules and which it spawns straight off PATH. `deno` is the
|
|
68
|
+
// single entry with no package, which is why it cannot be relied on.
|
|
69
|
+
const packageNames = {
|
|
70
|
+
dprint: 'dprint',
|
|
71
|
+
deno: null,
|
|
72
|
+
oxfmt: 'oxfmt',
|
|
73
|
+
biome: '@biomejs/biome',
|
|
74
|
+
prettier: 'prettier',
|
|
75
|
+
};
|
|
76
|
+
const packageName = packageNames[config.format];
|
|
77
|
+
assert.ok(
|
|
78
|
+
packageName,
|
|
79
|
+
`${config.format} is spawned straight off PATH with no npx fallback, ` +
|
|
80
|
+
'so it cannot be relied on inside a release job'
|
|
81
|
+
);
|
|
82
|
+
assert.ok(
|
|
83
|
+
existsSync(join(packageRoot, 'node_modules', packageName)),
|
|
84
|
+
`${packageName} is not installed in this package`
|
|
85
|
+
);
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
it('leaves no consumed changeset behind', () => {
|
|
89
|
+
const changesetDir = join(packageRoot, '.changeset');
|
|
90
|
+
const pending = readdirSync(changesetDir).filter(
|
|
91
|
+
(file) => file.endsWith('.md') && file !== 'README.md'
|
|
92
|
+
);
|
|
93
|
+
|
|
94
|
+
const changelog = readFileSync(join(packageRoot, 'CHANGELOG.md'), 'utf-8');
|
|
95
|
+
for (const file of pending) {
|
|
96
|
+
const body = readFileSync(join(changesetDir, file), 'utf-8')
|
|
97
|
+
.replace(/^---\s*\n[\s\S]*?\n---\s*\n/, '')
|
|
98
|
+
.trim();
|
|
99
|
+
const firstLine = body.split('\n')[0].trim();
|
|
100
|
+
assert.ok(
|
|
101
|
+
firstLine.length === 0 || !changelog.includes(firstLine),
|
|
102
|
+
`.changeset/${file} describes a change that CHANGELOG.md already ` +
|
|
103
|
+
'records as released; `changeset version` consumed it but did not ' +
|
|
104
|
+
'delete it, so the next release will publish it again'
|
|
105
|
+
);
|
|
106
|
+
}
|
|
107
|
+
});
|
|
108
|
+
});
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Guard for RC-E: the release commit that no workflow run ever checks.
|
|
3
|
+
*
|
|
4
|
+
* `version-and-commit.mjs` pushes the bump with `GITHUB_TOKEN`, and a push
|
|
5
|
+
* authenticated with `GITHUB_TOKEN` does not start a workflow run. When
|
|
6
|
+
* `changeset version` wrote a CHANGELOG.md that Prettier rejects, main's
|
|
7
|
+
* `format:check` went red and stayed red: the runs that could have reported it
|
|
8
|
+
* were exactly the ones the push never triggered.
|
|
9
|
+
*
|
|
10
|
+
* Analysis: dev/log/issues/83/pulls/84/analysis/root-causes.md, RC-E
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import assert from 'node:assert/strict';
|
|
14
|
+
import { readFileSync } from 'node:fs';
|
|
15
|
+
import { dirname, join } from 'node:path';
|
|
16
|
+
import { fileURLToPath } from 'node:url';
|
|
17
|
+
import { describe, it } from 'node:test';
|
|
18
|
+
|
|
19
|
+
import {
|
|
20
|
+
FORMAT_CHECK_COMMAND,
|
|
21
|
+
assertGeneratedFilesAreFormatted,
|
|
22
|
+
formatFailureAnnotation,
|
|
23
|
+
formatFailureDetails,
|
|
24
|
+
} from '../../../scripts/check-release-format.mjs';
|
|
25
|
+
|
|
26
|
+
const jsRoot = join(dirname(fileURLToPath(import.meta.url)), '../../..');
|
|
27
|
+
|
|
28
|
+
/** @returns {{log: (m: string) => void, error: (m: string) => void, lines: string[], errors: string[]}} */
|
|
29
|
+
function recorder() {
|
|
30
|
+
const lines = [];
|
|
31
|
+
const errors = [];
|
|
32
|
+
return {
|
|
33
|
+
lines,
|
|
34
|
+
errors,
|
|
35
|
+
log: (message) => lines.push(String(message)),
|
|
36
|
+
error: (message) => errors.push(String(message)),
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
describe('release commit formatting gate', () => {
|
|
41
|
+
it('lets a formatted release commit through', async () => {
|
|
42
|
+
const console_ = recorder();
|
|
43
|
+
let ran = 0;
|
|
44
|
+
|
|
45
|
+
await assertGeneratedFilesAreFormatted(async () => {
|
|
46
|
+
ran += 1;
|
|
47
|
+
}, console_);
|
|
48
|
+
|
|
49
|
+
assert.equal(ran, 1);
|
|
50
|
+
assert.equal(console_.errors.length, 0);
|
|
51
|
+
assert.ok(console_.lines.some((line) => line.includes('is formatted')));
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it('stops the release when the generated files fail the gate', async () => {
|
|
55
|
+
const console_ = recorder();
|
|
56
|
+
const failure = Object.assign(
|
|
57
|
+
new Error('Command failed with exit code 1'),
|
|
58
|
+
{ stdout: '[warn] CHANGELOG.md\n', stderr: '' }
|
|
59
|
+
);
|
|
60
|
+
|
|
61
|
+
await assert.rejects(
|
|
62
|
+
assertGeneratedFilesAreFormatted(async () => {
|
|
63
|
+
throw failure;
|
|
64
|
+
}, console_),
|
|
65
|
+
// Rethrown unchanged: the caller's catch reports it and exits non-zero,
|
|
66
|
+
// so the push never happens.
|
|
67
|
+
(error) => error === failure
|
|
68
|
+
);
|
|
69
|
+
|
|
70
|
+
assert.ok(console_.errors[0].startsWith('::error title='));
|
|
71
|
+
assert.ok(console_.errors.includes('[warn] CHANGELOG.md'));
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it('keeps the annotation on one line', () => {
|
|
75
|
+
// A workflow command ends at the first newline; anything after it is
|
|
76
|
+
// printed as plain log text and never becomes an annotation.
|
|
77
|
+
assert.ok(!formatFailureAnnotation().includes('\n'));
|
|
78
|
+
assert.ok(formatFailureAnnotation().startsWith('::error title='));
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it('reads the reason out of the captured streams, not the message', () => {
|
|
82
|
+
// With errexit on, `error.message` is only "Command failed with exit code
|
|
83
|
+
// N". A call site that reported `message` would say nothing useful.
|
|
84
|
+
const details = formatFailureDetails({
|
|
85
|
+
message: 'Command failed with exit code 1',
|
|
86
|
+
stdout: ' [warn] CHANGELOG.md ',
|
|
87
|
+
stderr: 'prettier: not found',
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
assert.equal(details, '[warn] CHANGELOG.md\nprettier: not found');
|
|
91
|
+
assert.equal(formatFailureDetails(new Error('boom')), '');
|
|
92
|
+
assert.equal(formatFailureDetails(undefined), '');
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
it('runs the same gate the lint job runs', () => {
|
|
96
|
+
// A gate that drifts from the one CI uses would pass here and fail there.
|
|
97
|
+
const workflow = readFileSync(
|
|
98
|
+
join(jsRoot, '../.github/workflows/js.yml'),
|
|
99
|
+
'utf8'
|
|
100
|
+
);
|
|
101
|
+
assert.ok(
|
|
102
|
+
workflow.includes(`run: ${FORMAT_CHECK_COMMAND}`),
|
|
103
|
+
`${FORMAT_CHECK_COMMAND} is no longer the workflow's formatting gate`
|
|
104
|
+
);
|
|
105
|
+
|
|
106
|
+
const script = readFileSync(
|
|
107
|
+
join(jsRoot, 'scripts/version-and-commit.mjs'),
|
|
108
|
+
'utf8'
|
|
109
|
+
);
|
|
110
|
+
assert.ok(
|
|
111
|
+
script.includes(FORMAT_CHECK_COMMAND),
|
|
112
|
+
'version-and-commit.mjs must run the same command it documents'
|
|
113
|
+
);
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
it('checks before it commits, not after it pushes', () => {
|
|
117
|
+
const script = readFileSync(
|
|
118
|
+
join(jsRoot, 'scripts/version-and-commit.mjs'),
|
|
119
|
+
'utf8'
|
|
120
|
+
);
|
|
121
|
+
const check = script.indexOf('assertGeneratedFilesAreFormatted(() =>');
|
|
122
|
+
const commit = script.indexOf('git commit');
|
|
123
|
+
// The raw `git push origin main` this used to look for is now
|
|
124
|
+
// pushWithRebaseRetry, which rebases when another release wrote to main
|
|
125
|
+
// first. See dev/log/issues/85/pulls/86/analysis/root-causes.md (RC-1).
|
|
126
|
+
const push = script.indexOf('pushWithRebaseRetry({');
|
|
127
|
+
|
|
128
|
+
assert.ok(check > 0 && commit > 0 && push > 0);
|
|
129
|
+
assert.ok(
|
|
130
|
+
check < commit && check < push,
|
|
131
|
+
'a check that runs after the push cannot stop a bad release commit'
|
|
132
|
+
);
|
|
133
|
+
});
|
|
134
|
+
});
|
|
@@ -260,3 +260,106 @@ jobs:
|
|
|
260
260
|
});
|
|
261
261
|
});
|
|
262
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);
|
|
364
|
+
});
|
|
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
|
+
});
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Guard for the failure mode that made three release defects invisible.
|
|
3
|
+
*
|
|
4
|
+
* `command-stream` resolves its `$` promise on a non-zero exit code instead of
|
|
5
|
+
* rejecting, so the idiom every release script in this repository is written
|
|
6
|
+
* around —
|
|
7
|
+
*
|
|
8
|
+
* try { await $`some-command`; } catch { process.exit(1); }
|
|
9
|
+
*
|
|
10
|
+
* — is dead code under the library's defaults. On 2026-09-05 that turned a
|
|
11
|
+
* crashed `changeset version` into a green JS release (run 33974450016) and a
|
|
12
|
+
* Rust release that never committed its version bump into a green Rust release
|
|
13
|
+
* (run 33974450069). See dev/log/issues/83/pulls/84/analysis/root-causes.md,
|
|
14
|
+
* RC-B and RC-C.
|
|
15
|
+
*
|
|
16
|
+
* `loadCommandStream()` is the single place every consumer obtains `$` from, so
|
|
17
|
+
* it is the single place the default can be corrected. These tests pin that:
|
|
18
|
+
* a non-zero exit must reject, and the rejection must carry the exit code so a
|
|
19
|
+
* caller can still branch on it.
|
|
20
|
+
*
|
|
21
|
+
* The tests need network access, because use-m is fetched from a CDN. When it
|
|
22
|
+
* is unreachable they skip, matching use-module-integration.test.js.
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
import assert from 'node:assert/strict';
|
|
26
|
+
import { describe, it } from 'node:test';
|
|
27
|
+
|
|
28
|
+
import {
|
|
29
|
+
loadCommandStream,
|
|
30
|
+
USE_M_URL,
|
|
31
|
+
} from '../../../../scripts/use-module.mjs';
|
|
32
|
+
|
|
33
|
+
async function hasNetwork() {
|
|
34
|
+
try {
|
|
35
|
+
return (await fetch(USE_M_URL, { method: 'HEAD' })).ok;
|
|
36
|
+
} catch {
|
|
37
|
+
return false;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Load command-stream, or a reason to skip.
|
|
43
|
+
* @param {import('node:test').TestContext} t
|
|
44
|
+
* @returns {Promise<Record<string, any> | null>}
|
|
45
|
+
*/
|
|
46
|
+
async function load(t) {
|
|
47
|
+
if (process.platform === 'win32') {
|
|
48
|
+
// Same upstream limitation use-module-integration.test.js documents:
|
|
49
|
+
// use-m imports the resolved file by absolute path, which the Windows ESM
|
|
50
|
+
// loader rejects. The Linux and macOS runs cover this.
|
|
51
|
+
t.skip('use-m cannot import absolute paths on Windows');
|
|
52
|
+
return null;
|
|
53
|
+
}
|
|
54
|
+
if (!(await hasNetwork())) {
|
|
55
|
+
t.skip(`${USE_M_URL} is unreachable, so use-m cannot be evaluated`);
|
|
56
|
+
return null;
|
|
57
|
+
}
|
|
58
|
+
try {
|
|
59
|
+
return await loadCommandStream();
|
|
60
|
+
} catch (error) {
|
|
61
|
+
if (
|
|
62
|
+
/fetch|network|ENOTFOUND|EAI_AGAIN|ETIMEDOUT|registry/i.test(
|
|
63
|
+
error.message
|
|
64
|
+
)
|
|
65
|
+
) {
|
|
66
|
+
t.skip(error.message);
|
|
67
|
+
return null;
|
|
68
|
+
}
|
|
69
|
+
throw error;
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
describe('loadCommandStream makes a failed command fail', () => {
|
|
74
|
+
it('turns errexit on, so the release scripts catch what they try to catch', async (t) => {
|
|
75
|
+
const cs = await load(t);
|
|
76
|
+
if (!cs) {
|
|
77
|
+
return;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
assert.equal(
|
|
81
|
+
cs.shell.settings().errexit,
|
|
82
|
+
true,
|
|
83
|
+
'errexit must be enabled by loadCommandStream; with the library default ' +
|
|
84
|
+
'of false, every try/catch around $ in the release scripts is unreachable'
|
|
85
|
+
);
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
it('rejects on a non-zero exit and reports the exit code', async (t) => {
|
|
89
|
+
const cs = await load(t);
|
|
90
|
+
if (!cs) {
|
|
91
|
+
return;
|
|
92
|
+
}
|
|
93
|
+
const { $ } = cs;
|
|
94
|
+
|
|
95
|
+
await assert.rejects(
|
|
96
|
+
() => $`exit 7`,
|
|
97
|
+
(error) => {
|
|
98
|
+
assert.equal(error.code, 7, 'the exit code must survive on the error');
|
|
99
|
+
return true;
|
|
100
|
+
},
|
|
101
|
+
'a command exiting 7 must reject, not resolve'
|
|
102
|
+
);
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
it('still resolves with stdout on success', async (t) => {
|
|
106
|
+
const cs = await load(t);
|
|
107
|
+
if (!cs) {
|
|
108
|
+
return;
|
|
109
|
+
}
|
|
110
|
+
const { $ } = cs;
|
|
111
|
+
|
|
112
|
+
// errexit must not turn every command into a throw: the success path is
|
|
113
|
+
// what the release scripts read version numbers and git status out of.
|
|
114
|
+
const result = await $`echo errexit-ok`;
|
|
115
|
+
assert.match(String(result.stdout), /errexit-ok/);
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
it('reports a non-zero exit for the exact command that broke the Rust release', async (t) => {
|
|
119
|
+
const cs = await load(t);
|
|
120
|
+
if (!cs) {
|
|
121
|
+
return;
|
|
122
|
+
}
|
|
123
|
+
const { $ } = cs;
|
|
124
|
+
|
|
125
|
+
// `git diff --cached --quiet` exits 1 when there ARE staged changes.
|
|
126
|
+
// rust/scripts/version-and-commit.mjs relied on that becoming a rejection
|
|
127
|
+
// and, because it did not, reported "No changes to commit" after staging
|
|
128
|
+
// two modified files. `false` stands in for it here so the test needs no
|
|
129
|
+
// git fixture; the contract under test is identical.
|
|
130
|
+
await assert.rejects(() => $`false`, 'a command exiting 1 must reject');
|
|
131
|
+
});
|
|
132
|
+
});
|
|
@@ -70,6 +70,11 @@ const MIRRORED_COMMANDS = [
|
|
|
70
70
|
workflow: 'quality.yml',
|
|
71
71
|
command: 'bash scripts/check-shared-fingerprint-assets.sh',
|
|
72
72
|
},
|
|
73
|
+
{
|
|
74
|
+
id: 'required-docs',
|
|
75
|
+
workflow: 'quality.yml',
|
|
76
|
+
command: 'bash scripts/check-required-docs.sh',
|
|
77
|
+
},
|
|
73
78
|
{
|
|
74
79
|
id: 'repo-scripts-lint',
|
|
75
80
|
workflow: 'quality.yml',
|