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.
@@ -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": [],
@@ -3,6 +3,7 @@
3
3
  "fingerprints": {
4
4
  "00d381dd611ae8ac": 1,
5
5
  "029a62ef1f48e856": 1,
6
+ "03241c1f33f40d14": 1,
6
7
  "038170e72e0af1f5": 1,
7
8
  "04b28d01d0bb47dc": 1,
8
9
  "04f605be138bc350": 1,
@@ -18,7 +19,7 @@
18
19
  "0caa402c4eae32d8": 1,
19
20
  "0fd23be8410c2c95": 1,
20
21
  "0fe4f3b5e5e939b3": 1,
21
- "10b6201c3ca1fbe0": 1,
22
+ "1019ebbe7f07a5ad": 1,
22
23
  "1273365073edd790": 1,
23
24
  "1296adc3c3128048": 1,
24
25
  "13c4b58d3ea0719b": 1,
@@ -30,6 +31,7 @@
30
31
  "1ac9fbd87efdb313": 1,
31
32
  "1ad4dc7e19a77f68": 1,
32
33
  "1c1baf1bc8ea0eae": 1,
34
+ "1cbd39b416a2b94b": 1,
33
35
  "1d61f5c49f12d2dc": 1,
34
36
  "1ef570563d4e5a08": 1,
35
37
  "1f2104c950b12a30": 1,
@@ -51,6 +53,7 @@
51
53
  "2c0f8b5c6f28420b": 1,
52
54
  "2d2d6fd6c7c0f30d": 1,
53
55
  "2e70be24d2268673": 1,
56
+ "2f4c9810eae87d6f": 1,
54
57
  "2f9e15467cc2f12a": 1,
55
58
  "3101802d3ea65339": 1,
56
59
  "33357ce94e07cc4b": 1,
@@ -60,7 +63,6 @@
60
63
  "3a9f1672ecc1d55b": 1,
61
64
  "3adca52a3f04837c": 1,
62
65
  "3c1017def7b3216c": 1,
63
- "3d7e17ad32108a7f": 1,
64
66
  "3da23cbee214a1a5": 1,
65
67
  "3dced48baf70eb36": 1,
66
68
  "3fa2ebbb416120f0": 1,
@@ -73,6 +75,7 @@
73
75
  "46e3f58cfb14a43a": 1,
74
76
  "4b0d3cc5c27227e7": 1,
75
77
  "4c3b94caa28045d0": 1,
78
+ "4e090d6bb30e6551": 1,
76
79
  "4f3c541b3f3e3f23": 1,
77
80
  "4fe5663c69a426cb": 1,
78
81
  "505d98bd14b63a6c": 1,
@@ -97,6 +100,7 @@
97
100
  "5ae9b1430fc38daf": 1,
98
101
  "602bfd129bba900e": 1,
99
102
  "608e1dd27138ec6f": 1,
103
+ "6098544c77ff17a2": 1,
100
104
  "60d0c58a01fc8746": 1,
101
105
  "61bcadcd8dc9f63e": 1,
102
106
  "63a3b672c8d8b50b": 1,
@@ -109,6 +113,7 @@
109
113
  "69d50e6f6a5f2ee9": 1,
110
114
  "6a7ed9ad3fc00a39": 1,
111
115
  "6af3c307939ef75e": 1,
116
+ "6b35b48512756192": 1,
112
117
  "6cd00d6b6a039022": 1,
113
118
  "6e2457cd3e1f69e7": 1,
114
119
  "6fa62e2642cc73c3": 1,
@@ -154,6 +159,7 @@
154
159
  "97c5a112fdde7be8": 1,
155
160
  "98cc22079feaa004": 1,
156
161
  "990d87f21678fa96": 1,
162
+ "9a72b1c2f22156ca": 1,
157
163
  "9b6ef4df04562546": 1,
158
164
  "9c055dfd90c369bf": 1,
159
165
  "9cb19f1b972e811e": 1,
@@ -196,6 +202,7 @@
196
202
  "be0900fb3e87164e": 1,
197
203
  "bf452b9431c092ad": 1,
198
204
  "c0b027bebd7bd737": 1,
205
+ "c189fe5122a7dc91": 1,
199
206
  "c2c155adbcb46f3c": 1,
200
207
  "c30737d62069fc02": 1,
201
208
  "c3c3569357673af8": 1,
@@ -203,15 +210,16 @@
203
210
  "c461a83b51eb7740": 1,
204
211
  "c9cf3c28bed33a5b": 1,
205
212
  "cbd444e9a6a9072e": 1,
206
- "cc60029bf62920a3": 1,
207
213
  "cd49f39b43d14635": 1,
208
214
  "cebff7d1cc87f646": 1,
209
215
  "cf6116d195199f8a": 1,
210
216
  "cfd68135b744c27b": 1,
211
217
  "d097f41a6f43e634": 1,
218
+ "d0da8714c4b7de30": 1,
212
219
  "d17201496391f6cc": 1,
213
220
  "d17e8db5a2bab966": 1,
214
221
  "d1a4a8e6a523b752": 1,
222
+ "d1e5c2961267399a": 1,
215
223
  "d2ed3d02dd6a2d8c": 1,
216
224
  "d4eec99f58906a63": 1,
217
225
  "d6157a86fea41907": 1,
@@ -233,7 +241,6 @@
233
241
  "eb9a70c2a21094ff": 1,
234
242
  "ebd08637a2d7bcf3": 1,
235
243
  "ebe03c5f8c2f5147": 1,
236
- "edccf5a595ab439f": 1,
237
244
  "ee19c8b9b332ee98": 1,
238
245
  "eeca0ccf8d821f21": 1,
239
246
  "ef2fc2e92ac07c6e": 1,
@@ -244,6 +251,7 @@
244
251
  "f46509ed693574df": 1,
245
252
  "f4bb6f4c83950fcd": 1,
246
253
  "f4bfa7e503443cd1": 1,
254
+ "f6678d6748e9c902": 1,
247
255
  "f67a07a611f39cb2": 1,
248
256
  "f911821aa7dda47f": 1,
249
257
  "f97bf515bfd95829": 1,
package/CHANGELOG.md CHANGED
@@ -1,11 +1,36 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.17.2
4
+
5
+ ### Patch Changes
6
+
7
+ - 539ae64: Recover a release push that lost the race to `main` instead of failing the run.
8
+
9
+ The `main-writer` concurrency group serialises the three release jobs correctly,
10
+ but `actions/checkout` checks out `github.sha`, so every writer after the first
11
+ holds a tree one commit behind `main` and its push is rejected as
12
+ non-fast-forward. Serialisation buys ordering, not freshness. Every push to
13
+ `main` now rebases and retries — after classifying the rejection, because a
14
+ GH006/GH013 ruleset rejection also prints "rejected" and can never be rebased
15
+ away.
16
+
17
+ ## 0.17.1
18
+
19
+ ### Patch Changes
20
+
21
+ - 43708ff: Make the release scripts fail when the commands they run fail.
22
+
23
+ `command-stream`'s `$` resolves rather than rejects on a non-zero exit, so every
24
+ `try/catch` around it in the release scripts was dead code. `loadCommandStream()`
25
+ now turns on `errexit`, the GitHub release tags are namespaced per language, and
26
+ the release job checks the formatting of the commit it is about to push.
27
+
3
28
  ## 0.17.0
4
29
 
5
30
  ### Minor Changes
6
31
 
7
32
  - 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
-
33
+
9
34
  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
35
 
11
36
  ## 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.2",
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,12 @@
14
14
 
15
15
  import { readFileSync, appendFileSync, readdirSync } from 'fs';
16
16
 
17
+ import { assertGeneratedFilesAreFormatted } from './check-release-format.mjs';
18
+
19
+ import {
20
+ createGitRunner,
21
+ pushWithRebaseRetry,
22
+ } from '../../scripts/push-with-rebase-retry.mjs';
17
23
  import {
18
24
  loadCommandStream,
19
25
  loadLinoArguments,
@@ -205,6 +211,16 @@ async function main() {
205
211
  console.log(`New version: ${newVersion}`);
206
212
  setOutput('new_version', newVersion);
207
213
 
214
+ // Re-run the formatting gate over the files the bump just generated.
215
+ //
216
+ // The release commit is pushed with GITHUB_TOKEN, and a GITHUB_TOKEN push
217
+ // does not start a workflow run, so no job ever inspects what the bump
218
+ // wrote. `changeset version` once produced a CHANGELOG.md that Prettier
219
+ // rejects; main's format:check went red and stayed red, because the only
220
+ // runs that could have caught it were the ones the push never triggered.
221
+ // Checking here stops a malformed release commit before it lands.
222
+ await assertGeneratedFilesAreFormatted(() => $`npm run format:check`);
223
+
208
224
  // Check if there are changes to commit
209
225
  const statusResult = await $`git status --porcelain`.run({ capture: true });
210
226
  const status = statusResult.stdout.trim();
@@ -220,8 +236,15 @@ async function main() {
220
236
  const escapedMessage = commitMessage.replace(/"/g, '\\"');
221
237
  await $`git commit -m "${escapedMessage}"`;
222
238
 
223
- // Push directly to main
224
- await $`git push origin main`;
239
+ // Push directly to main.
240
+ //
241
+ // The pre-emptive rebase above runs before the version bump, so it
242
+ // cannot cover a writer that lands between the bump and this push.
243
+ // pushWithRebaseRetry closes that window and, just as importantly,
244
+ // distinguishes a lost race from a branch-protection rejection instead
245
+ // of retrying one as if it were the other.
246
+ const git = await createGitRunner();
247
+ await pushWithRebaseRetry({ git, branch: 'main' });
225
248
 
226
249
  console.log('\u2705 Version bump committed and pushed to main');
227
250
  setOutput('version_committed', 'true');
@@ -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
+ });