browser-commander 0.17.0 → 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.
@@ -1,6 +1,7 @@
1
1
  {
2
2
  "$schema": "https://unpkg.com/@changesets/config@4.0.0/schema.json",
3
3
  "changelog": "@changesets/cli/changelog",
4
+ "format": "prettier",
4
5
  "commit": false,
5
6
  "fixed": [],
6
7
  "linked": [],
@@ -18,7 +18,7 @@
18
18
  "0caa402c4eae32d8": 1,
19
19
  "0fd23be8410c2c95": 1,
20
20
  "0fe4f3b5e5e939b3": 1,
21
- "10b6201c3ca1fbe0": 1,
21
+ "1019ebbe7f07a5ad": 1,
22
22
  "1273365073edd790": 1,
23
23
  "1296adc3c3128048": 1,
24
24
  "13c4b58d3ea0719b": 1,
@@ -51,6 +51,7 @@
51
51
  "2c0f8b5c6f28420b": 1,
52
52
  "2d2d6fd6c7c0f30d": 1,
53
53
  "2e70be24d2268673": 1,
54
+ "2f4c9810eae87d6f": 1,
54
55
  "2f9e15467cc2f12a": 1,
55
56
  "3101802d3ea65339": 1,
56
57
  "33357ce94e07cc4b": 1,
@@ -60,7 +61,6 @@
60
61
  "3a9f1672ecc1d55b": 1,
61
62
  "3adca52a3f04837c": 1,
62
63
  "3c1017def7b3216c": 1,
63
- "3d7e17ad32108a7f": 1,
64
64
  "3da23cbee214a1a5": 1,
65
65
  "3dced48baf70eb36": 1,
66
66
  "3fa2ebbb416120f0": 1,
@@ -73,6 +73,7 @@
73
73
  "46e3f58cfb14a43a": 1,
74
74
  "4b0d3cc5c27227e7": 1,
75
75
  "4c3b94caa28045d0": 1,
76
+ "4e090d6bb30e6551": 1,
76
77
  "4f3c541b3f3e3f23": 1,
77
78
  "4fe5663c69a426cb": 1,
78
79
  "505d98bd14b63a6c": 1,
@@ -109,6 +110,7 @@
109
110
  "69d50e6f6a5f2ee9": 1,
110
111
  "6a7ed9ad3fc00a39": 1,
111
112
  "6af3c307939ef75e": 1,
113
+ "6b35b48512756192": 1,
112
114
  "6cd00d6b6a039022": 1,
113
115
  "6e2457cd3e1f69e7": 1,
114
116
  "6fa62e2642cc73c3": 1,
@@ -140,6 +142,7 @@
140
142
  "876d2c8edfc8c40a": 1,
141
143
  "8b013ac20e436d76": 1,
142
144
  "8c2b1408886c1db1": 1,
145
+ "8c36ed4fc291ae47": 1,
143
146
  "8cb9a984bf19a062": 1,
144
147
  "8ec18cdbd4a96518": 1,
145
148
  "8ecf2e554a04d2ef": 1,
@@ -154,6 +157,7 @@
154
157
  "97c5a112fdde7be8": 1,
155
158
  "98cc22079feaa004": 1,
156
159
  "990d87f21678fa96": 1,
160
+ "9a72b1c2f22156ca": 1,
157
161
  "9b6ef4df04562546": 1,
158
162
  "9c055dfd90c369bf": 1,
159
163
  "9cb19f1b972e811e": 1,
@@ -196,6 +200,7 @@
196
200
  "be0900fb3e87164e": 1,
197
201
  "bf452b9431c092ad": 1,
198
202
  "c0b027bebd7bd737": 1,
203
+ "c189fe5122a7dc91": 1,
199
204
  "c2c155adbcb46f3c": 1,
200
205
  "c30737d62069fc02": 1,
201
206
  "c3c3569357673af8": 1,
@@ -203,15 +208,16 @@
203
208
  "c461a83b51eb7740": 1,
204
209
  "c9cf3c28bed33a5b": 1,
205
210
  "cbd444e9a6a9072e": 1,
206
- "cc60029bf62920a3": 1,
207
211
  "cd49f39b43d14635": 1,
208
212
  "cebff7d1cc87f646": 1,
209
213
  "cf6116d195199f8a": 1,
210
214
  "cfd68135b744c27b": 1,
211
215
  "d097f41a6f43e634": 1,
216
+ "d0da8714c4b7de30": 1,
212
217
  "d17201496391f6cc": 1,
213
218
  "d17e8db5a2bab966": 1,
214
219
  "d1a4a8e6a523b752": 1,
220
+ "d1e5c2961267399a": 1,
215
221
  "d2ed3d02dd6a2d8c": 1,
216
222
  "d4eec99f58906a63": 1,
217
223
  "d6157a86fea41907": 1,
@@ -233,7 +239,6 @@
233
239
  "eb9a70c2a21094ff": 1,
234
240
  "ebd08637a2d7bcf3": 1,
235
241
  "ebe03c5f8c2f5147": 1,
236
- "edccf5a595ab439f": 1,
237
242
  "ee19c8b9b332ee98": 1,
238
243
  "eeca0ccf8d821f21": 1,
239
244
  "ef2fc2e92ac07c6e": 1,
@@ -244,6 +249,7 @@
244
249
  "f46509ed693574df": 1,
245
250
  "f4bb6f4c83950fcd": 1,
246
251
  "f4bfa7e503443cd1": 1,
252
+ "f6678d6748e9c902": 1,
247
253
  "f67a07a611f39cb2": 1,
248
254
  "f911821aa7dda47f": 1,
249
255
  "f97bf515bfd95829": 1,
package/CHANGELOG.md CHANGED
@@ -1,11 +1,22 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.17.1
4
+
5
+ ### Patch Changes
6
+
7
+ - 43708ff: Make the release scripts fail when the commands they run fail.
8
+
9
+ `command-stream`'s `$` resolves rather than rejects on a non-zero exit, so every
10
+ `try/catch` around it in the release scripts was dead code. `loadCommandStream()`
11
+ now turns on `errexit`, the GitHub release tags are namespaced per language, and
12
+ the release job checks the formatting of the commit it is about to push.
13
+
3
14
  ## 0.17.0
4
15
 
5
16
  ### Minor Changes
6
17
 
7
18
  - Restore the release pipeline. `const { $ } = await use('command-stream')` returned `undefined` on the Node 24 the release jobs pin, because Node 23 added a synthetic `'module.exports'` named export for CommonJS namespaces that use-m's unwrapping does not recognise, so every JS and Rust release died on its first shell call with `TypeError: $ is not a function`. Dependencies now load through `scripts/use-module.mjs`, which detects that namespace shape and unwraps it. Manifest fields are read by TOML table through `scripts/read-manifest.mjs` instead of a line-anchored `grep`, which matched duplicate keys in other tables and wrote two lines into `$GITHUB_OUTPUT`. The duplication gate had `format` set to a reporter name, so jscpd scanned zero files and passed unconditionally; it now scans the tree against a recorded baseline.
8
-
19
+
9
20
  Add the `fingerprint` subsystem, which removes the differences between a browser this library controls and one started by hand. `navigator.webdriver` stays false because the `AutomationControlled` Blink feature is disabled at launch, and `launchBrowser()` no longer passes the engine defaults a real Chrome does not carry. `resolveFingerprintProfile()` validates the 19 environment fields a page can read -- user agent and client hints, languages and locale, time zone, platform, cores, memory, screen, viewport, touch, WebGL strings, geolocation and the media preferences -- `createFingerprintPreset()` builds internally consistent Windows, macOS, Linux and Android machines, and `applyFingerprint()` installs a profile over CDP for both Playwright and Puppeteer. `FINGERPRINT_LIMITATIONS` documents what still cannot be made identical, and `relevantFingerprintLimitations()` narrows it to the entries a given profile and browser actually hit. The excluded defaults include Playwright's unconditional `--enable-unsafe-swiftshader`, which would otherwise give an automated browser a SwiftShader WebGL context on a machine where a hand-started Chrome has none.
10
21
 
11
22
  ## 0.16.1
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "browser-commander",
3
- "version": "0.17.0",
3
+ "version": "0.17.1",
4
4
  "description": "Universal browser automation library that supports both Playwright and Puppeteer with a unified API",
5
5
  "main": "src/index.js",
6
6
  "type": "module",
@@ -0,0 +1,80 @@
1
+ /**
2
+ * Guard the release commit against the formatting gate that never sees it.
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. Nothing
6
+ * therefore inspects the files the bump generated -- CHANGELOG.md,
7
+ * package.json, package-lock.json. When `changeset version` wrote a
8
+ * CHANGELOG.md that Prettier rejects, main's `format:check` went red and
9
+ * stayed red, because the only runs that could have reported it were the ones
10
+ * the push never triggered.
11
+ *
12
+ * This module has no side effects on import: the shell is passed in, so the
13
+ * behaviour is testable without running a release.
14
+ */
15
+
16
+ export const FORMAT_CHECK_COMMAND = 'npm run format:check';
17
+
18
+ export const FORMAT_FAILURE_TITLE = 'Release commit is not formatted';
19
+
20
+ /**
21
+ * Build the annotation shown when the generated files fail the gate.
22
+ *
23
+ * A workflow command ends at the first newline, so the message is a single
24
+ * line and the captured output is printed separately.
25
+ * @returns {string}
26
+ */
27
+ export function formatFailureAnnotation() {
28
+ return (
29
+ `::error title=${FORMAT_FAILURE_TITLE}::The version bump wrote files that ` +
30
+ 'Prettier rejects. Pushing them would leave main failing format:check, ' +
31
+ 'and the push does not trigger a run that would report it. Fix the ' +
32
+ 'generator (see .changeset/config.json "format") rather than reformatting ' +
33
+ 'by hand.'
34
+ );
35
+ }
36
+
37
+ /**
38
+ * Extract the reason a command-stream failure carries.
39
+ *
40
+ * With `errexit` on, `error.message` is only "Command failed with exit code
41
+ * N"; the diagnosis is in the captured streams.
42
+ * @param {unknown} error
43
+ * @returns {string}
44
+ */
45
+ export function formatFailureDetails(error) {
46
+ const streams = error && typeof error === 'object' ? error : {};
47
+ return [streams.stdout, streams.stderr]
48
+ .map((stream) => String(stream ?? '').trim())
49
+ .filter(Boolean)
50
+ .join('\n');
51
+ }
52
+
53
+ /**
54
+ * Run the repository's formatting gate over the generated release commit.
55
+ *
56
+ * Rethrows on failure so the release stops before the push.
57
+ * @param {() => Promise<unknown>} runCheck
58
+ * @param {{log?: (message: string) => void, error?: (message: string) => void}} [console_]
59
+ * @returns {Promise<void>}
60
+ */
61
+ export async function assertGeneratedFilesAreFormatted(
62
+ runCheck,
63
+ console_ = console
64
+ ) {
65
+ const log = console_.log ?? (() => {});
66
+ const error = console_.error ?? (() => {});
67
+
68
+ log('Checking that the release commit is formatted...');
69
+ try {
70
+ await runCheck();
71
+ } catch (failure) {
72
+ error(formatFailureAnnotation());
73
+ const details = formatFailureDetails(failure);
74
+ if (details) {
75
+ error(details);
76
+ }
77
+ throw failure;
78
+ }
79
+ log('Release commit is formatted');
80
+ }
@@ -14,7 +14,9 @@
14
14
 
15
15
  import { readFileSync } from 'fs';
16
16
 
17
+ import { releaseTag } from '../../scripts/release-tags.mjs';
17
18
  import {
19
+ commandErrorText,
18
20
  loadCommandStream,
19
21
  loadLinoArguments,
20
22
  } from '../../scripts/use-module.mjs';
@@ -49,7 +51,9 @@ if (!version || !repository) {
49
51
  process.exit(1);
50
52
  }
51
53
 
52
- const tag = `v${version}`;
54
+ // `v<version>` is this package's namespace only; the crate uses `rust-v` and
55
+ // the wheel `python-v`. See scripts/release-tags.mjs.
56
+ const tag = releaseTag('js', version);
53
57
 
54
58
  console.log(`Creating GitHub release for ${tag}...`);
55
59
 
@@ -68,7 +72,14 @@ try {
68
72
  releaseNotes = match[0].replace(`## ${version}`, '').trim();
69
73
  }
70
74
 
75
+ // Falling back to a bare "Release <version>" body is a silent downgrade:
76
+ // the release still goes out and the job still passes, so this annotation is
77
+ // what makes an empty release body visible in the run summary.
71
78
  if (!releaseNotes) {
79
+ console.warn(
80
+ `::warning::Version ${version} has no "## ${version}" section in ` +
81
+ 'CHANGELOG.md, releasing without notes'
82
+ );
72
83
  releaseNotes = `Release ${version}`;
73
84
  }
74
85
 
@@ -81,11 +92,21 @@ try {
81
92
  body: releaseNotes,
82
93
  });
83
94
 
84
- await $`gh api repos/${repository}/releases -X POST --input -`.run({
85
- stdin: payload,
86
- });
87
-
88
- console.log(`\u2705 Created GitHub release: ${tag}`);
95
+ try {
96
+ await $`gh api repos/${repository}/releases -X POST --input -`.run({
97
+ stdin: payload,
98
+ });
99
+ console.log(`\u2705 Created GitHub release: ${tag}`);
100
+ } catch (error) {
101
+ // Re-running a release over a tag that already has one is not a failure.
102
+ // The rust script has always tolerated it; the JS one did not need to
103
+ // while a failed `gh` call resolved instead of rejecting.
104
+ if (commandErrorText(error).includes('already exists')) {
105
+ console.log(`Release ${tag} already exists, skipping`);
106
+ } else {
107
+ throw error;
108
+ }
109
+ }
89
110
  } catch (error) {
90
111
  console.error('Error creating release:', error.message);
91
112
  process.exit(1);
@@ -14,6 +14,8 @@
14
14
 
15
15
  import { readFileSync, appendFileSync, readdirSync } from 'fs';
16
16
 
17
+ import { assertGeneratedFilesAreFormatted } from './check-release-format.mjs';
18
+
17
19
  import {
18
20
  loadCommandStream,
19
21
  loadLinoArguments,
@@ -205,6 +207,16 @@ async function main() {
205
207
  console.log(`New version: ${newVersion}`);
206
208
  setOutput('new_version', newVersion);
207
209
 
210
+ // Re-run the formatting gate over the files the bump just generated.
211
+ //
212
+ // The release commit is pushed with GITHUB_TOKEN, and a GITHUB_TOKEN push
213
+ // does not start a workflow run, so no job ever inspects what the bump
214
+ // wrote. `changeset version` once produced a CHANGELOG.md that Prettier
215
+ // rejects; main's format:check went red and stayed red, because the only
216
+ // runs that could have caught it were the ones the push never triggered.
217
+ // Checking here stops a malformed release commit before it lands.
218
+ await assertGeneratedFilesAreFormatted(() => $`npm run format:check`);
219
+
208
220
  // Check if there are changes to commit
209
221
  const statusResult = await $`git status --porcelain`.run({ capture: true });
210
222
  const status = statusResult.stdout.trim();
@@ -0,0 +1,110 @@
1
+ /**
2
+ * Guard for the lockfile half of RC-C.
3
+ *
4
+ * `rust/scripts/version-and-commit.mjs` writes the new version into
5
+ * Cargo.toml. Cargo.lock carries the workspace member's own version too, so a
6
+ * bump that touches only the manifest leaves the two disagreeing, and every
7
+ * job that builds the release commit with `cargo build --locked` fails with
8
+ * "the lock file needs to be updated but --locked was passed".
9
+ *
10
+ * The drift was invisible while RC-C was live: the release commit was never
11
+ * pushed, so main kept a Cargo.toml and a Cargo.lock that still agreed.
12
+ * Fixing RC-C is what makes this reachable.
13
+ *
14
+ * Analysis: dev/log/issues/83/pulls/84/analysis/root-causes.md, RC-C
15
+ */
16
+
17
+ import assert from 'node:assert/strict';
18
+ import { readFileSync } from 'node:fs';
19
+ import { dirname, join } from 'node:path';
20
+ import { fileURLToPath } from 'node:url';
21
+ import { describe, it } from 'node:test';
22
+
23
+ const repoRoot = join(dirname(fileURLToPath(import.meta.url)), '../../../..');
24
+
25
+ /**
26
+ * Read the version of a named package out of a Cargo.lock.
27
+ *
28
+ * The line separator is `\r?\n` because a Windows runner checks the file out
29
+ * with CRLF: `git config core.autocrlf` is `true` on GitHub's windows-latest
30
+ * image, so a pattern anchored on a bare `\n` matches nothing there and the
31
+ * lookup silently returns `undefined` -- a guard that passes on two operating
32
+ * systems and fails on the third for a reason that has nothing to do with what
33
+ * it guards.
34
+ *
35
+ * @param {string} lock
36
+ * @param {string} name
37
+ * @returns {string | undefined}
38
+ */
39
+ function lockedVersion(lock, name) {
40
+ const pattern = new RegExp(
41
+ `\\[\\[package\\]\\]\\r?\\nname = "${name}"\\r?\\nversion = "([^"]+)"`
42
+ );
43
+ return pattern.exec(lock)?.[1];
44
+ }
45
+
46
+ describe('Cargo.lock stays in step with the release bump', () => {
47
+ const cargoToml = readFileSync(join(repoRoot, 'rust/Cargo.toml'), 'utf8');
48
+ const cargoLock = readFileSync(join(repoRoot, 'rust/Cargo.lock'), 'utf8');
49
+ const script = readFileSync(
50
+ join(repoRoot, 'rust/scripts/version-and-commit.mjs'),
51
+ 'utf8'
52
+ );
53
+
54
+ it('records the manifest version in the lockfile today', () => {
55
+ const manifestVersion = /^\[package\][\s\S]*?^version = "([^"]+)"/m.exec(
56
+ cargoToml
57
+ )?.[1];
58
+ assert.ok(manifestVersion, 'Cargo.toml has no [package] version');
59
+ assert.equal(
60
+ lockedVersion(cargoLock, 'browser-commander'),
61
+ manifestVersion,
62
+ 'a build with --locked would refuse this tree'
63
+ );
64
+ });
65
+
66
+ it('reads a lockfile that was checked out with CRLF line endings', () => {
67
+ const crlf = cargoLock.replace(/\r?\n/g, '\r\n');
68
+ assert.equal(
69
+ lockedVersion(crlf, 'browser-commander'),
70
+ lockedVersion(cargoLock, 'browser-commander'),
71
+ 'the same lockfile must read the same on a Windows checkout'
72
+ );
73
+ });
74
+
75
+ it('refreshes the lockfile as part of the bump', () => {
76
+ assert.match(
77
+ script,
78
+ /cargo update --workspace/,
79
+ 'bumping Cargo.toml without refreshing Cargo.lock breaks --locked builds'
80
+ );
81
+ });
82
+
83
+ it('refreshes the lockfile after the manifest, not before', () => {
84
+ const bump = script.indexOf('updateCargoToml(newVersion)');
85
+ const refresh = script.indexOf('cargo update --workspace');
86
+ assert.ok(bump > 0 && refresh > 0);
87
+ assert.ok(
88
+ bump < refresh,
89
+ 'refreshing before the bump would re-lock the old version'
90
+ );
91
+ });
92
+
93
+ it('stages the lockfile in the release commit', () => {
94
+ const add = /git add -A ([^`]+)`/.exec(script)?.[1] ?? '';
95
+ assert.ok(
96
+ add.split(/\s+/).includes('Cargo.lock'),
97
+ `Cargo.lock is refreshed but never committed (staged: ${add.trim()})`
98
+ );
99
+ });
100
+
101
+ it('is what the release job later builds with --locked', () => {
102
+ // If the workflow stopped passing --locked the drift would go unnoticed
103
+ // again, so the guard is only meaningful while this flag is there.
104
+ const workflow = readFileSync(
105
+ join(repoRoot, '.github/workflows/rust.yml'),
106
+ 'utf8'
107
+ );
108
+ assert.match(workflow, /cargo build --locked/);
109
+ });
110
+ });
@@ -0,0 +1,144 @@
1
+ /**
2
+ * Guard for RC-I: collecting the changelog before the version bump.
3
+ *
4
+ * Both manual-release jobs used to collect fragments in a workflow step that
5
+ * ran ahead of the bump, and both named the section wrongly as a result:
6
+ *
7
+ * rust.yml `node scripts/collect-changelog.mjs` reads the version out of
8
+ * Cargo.toml, which at that point still held the version that
9
+ * was already released, so the notes were filed under it and
10
+ * the version being released got none.
11
+ * python.yml `scriv collect --version "$BUMP_TYPE"`. `--version` names the
12
+ * new changelog section rather than selecting a bump, so a
13
+ * release wrote a section titled "patch".
14
+ *
15
+ * Either way the release notes went missing: create-github-release looks for
16
+ * the version it is releasing and falls back to a bare "Release <version>".
17
+ *
18
+ * Collection now happens inside the version-and-commit scripts, after the
19
+ * bump has computed the version. These assertions are on the workflows,
20
+ * because that is where the ordering lived.
21
+ *
22
+ * Analysis: dev/log/issues/83/pulls/84/analysis/root-causes.md
23
+ * Reproduction: experiments/scriv-collect-version/run.sh
24
+ */
25
+
26
+ import assert from 'node:assert/strict';
27
+ import { readFileSync } from 'node:fs';
28
+ import { dirname, join } from 'node:path';
29
+ import { fileURLToPath } from 'node:url';
30
+ import { describe, it } from 'node:test';
31
+
32
+ const repoRoot = join(dirname(fileURLToPath(import.meta.url)), '../../../..');
33
+
34
+ /**
35
+ * Read a workflow with its comment lines removed.
36
+ *
37
+ * The comments explain the very mistakes these assertions look for, so
38
+ * matching against the raw file would find the explanation and report the bug
39
+ * as still present.
40
+ * @param {string} name
41
+ * @returns {string}
42
+ */
43
+ function workflow(name) {
44
+ return readFileSync(join(repoRoot, '.github/workflows', name), 'utf8')
45
+ .split('\n')
46
+ .filter((line) => !/^\s*#/.test(line))
47
+ .join('\n');
48
+ }
49
+
50
+ describe('changelog fragments are collected after the version bump', () => {
51
+ it('never passes a bump type where a version name is expected', () => {
52
+ // `scriv collect --version` is documented as "The version name to use for
53
+ // this entry", so "$BUMP_TYPE" lands in the changelog verbatim.
54
+ assert.doesNotMatch(
55
+ workflow('python.yml'),
56
+ /scriv collect --version "\$BUMP_TYPE"/,
57
+ 'scriv would head the release section with the word "patch"'
58
+ );
59
+ });
60
+
61
+ it('has no workflow step that collects ahead of the bump', () => {
62
+ for (const [name, collector] of [
63
+ ['rust.yml', 'collect-changelog.mjs'],
64
+ ['python.yml', 'scriv collect'],
65
+ ]) {
66
+ const content = workflow(name);
67
+ const collect = content.indexOf(collector);
68
+ if (collect < 0) {
69
+ continue;
70
+ }
71
+ const bump = content.indexOf('name: Version and commit');
72
+ assert.ok(
73
+ bump < 0 || collect > bump,
74
+ `${name} collects the changelog before the bump names the version`
75
+ );
76
+ }
77
+ });
78
+
79
+ it('collects inside the scripts that know the new version', () => {
80
+ const rust = readFileSync(
81
+ join(repoRoot, 'rust/scripts/version-and-commit.mjs'),
82
+ 'utf8'
83
+ );
84
+ assert.match(rust, /updateChangelog\('\.', newVersion, fragments\)/);
85
+
86
+ const python = readFileSync(
87
+ join(repoRoot, 'python/scripts/version_and_commit.py'),
88
+ 'utf8'
89
+ );
90
+ assert.match(python, /collect_changelog\(new_version, project_root\)/);
91
+ });
92
+
93
+ it('orders collection after the bump inside those scripts', () => {
94
+ const rust = readFileSync(
95
+ join(repoRoot, 'rust/scripts/version-and-commit.mjs'),
96
+ 'utf8'
97
+ );
98
+ assert.ok(
99
+ rust.indexOf('const newVersion = await findNextAvailableVersion') <
100
+ rust.indexOf('collectFragments()')
101
+ );
102
+
103
+ const python = readFileSync(
104
+ join(repoRoot, 'python/scripts/version_and_commit.py'),
105
+ 'utf8'
106
+ );
107
+ assert.ok(
108
+ python.indexOf('new_version = bump_version(') <
109
+ python.indexOf('collect_changelog(new_version')
110
+ );
111
+ });
112
+
113
+ it('writes headings at the level the release scripts read', () => {
114
+ // scriv defaults to level 1 while create_github_release.py extracts from
115
+ // `## <version>`, so the two could never meet.
116
+ const pyproject = readFileSync(
117
+ join(repoRoot, 'python/pyproject.toml'),
118
+ 'utf8'
119
+ );
120
+ assert.match(pyproject, /^md_header_level = "2"$/m);
121
+
122
+ const release = readFileSync(
123
+ join(repoRoot, 'python/scripts/create_github_release.py'),
124
+ 'utf8'
125
+ );
126
+ assert.match(release, /rf"\^## \{re\.escape\(version\)\}/);
127
+ });
128
+
129
+ it('says so in the run summary when the notes are missing anyway', () => {
130
+ // The fallback body is a silent downgrade: the release still goes out and
131
+ // the job still passes.
132
+ for (const path of [
133
+ 'js/scripts/create-github-release.mjs',
134
+ 'rust/scripts/create-github-release.mjs',
135
+ 'python/scripts/create_github_release.py',
136
+ ]) {
137
+ assert.match(
138
+ readFileSync(join(repoRoot, path), 'utf8'),
139
+ /::warning::/,
140
+ `${path} drops the release notes without a word`
141
+ );
142
+ }
143
+ });
144
+ });
@@ -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
+ });