browser-commander 0.16.1 → 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.
- package/.changeset/config.json +2 -1
- package/.jscpd-baseline.json +263 -0
- package/.jscpd.json +1 -3
- package/.prettierignore +1 -0
- package/CHANGELOG.md +19 -0
- package/eslint.config.js +23 -1
- package/package.json +13 -24
- package/scripts/changeset-version.mjs +2 -6
- package/scripts/check-release-format.mjs +80 -0
- package/scripts/create-github-release.mjs +33 -13
- package/scripts/create-manual-changeset.mjs +7 -8
- package/scripts/format-github-release.mjs +6 -7
- package/scripts/format-release-notes.mjs +7 -8
- package/scripts/instant-version-bump.mjs +7 -8
- package/scripts/publish-to-npm.mjs +7 -8
- package/scripts/setup-npm.mjs +2 -6
- package/scripts/version-and-commit.mjs +18 -7
- package/src/browser/browser-cookie-credentials.js +3 -1
- package/src/browser/browser-cookies.js +6 -2
- package/src/browser/browser-profiles.js +1 -1
- package/src/browser/launch-options.js +31 -8
- package/src/browser/launcher.js +16 -0
- package/src/exports.js +34 -0
- package/src/fingerprint/apply.js +116 -0
- package/src/fingerprint/automation-parity.js +224 -0
- package/src/fingerprint/cdp-overrides.js +176 -0
- package/src/fingerprint/derive.js +104 -0
- package/src/fingerprint/init-payload.js +181 -0
- package/src/fingerprint/init-script.js +104 -0
- package/src/fingerprint/limitations.js +107 -0
- package/src/fingerprint/limitations.json +96 -0
- package/src/fingerprint/presets.js +244 -0
- package/src/fingerprint/profile.js +428 -0
- package/src/tests/index.js +2 -1
- package/tests/e2e/fingerprint-parity.e2e.test.js +212 -0
- package/tests/helpers/mocks.js +4 -2
- package/tests/helpers/repo.js +74 -0
- package/tests/unit/browser/launch-options.test.js +9 -1
- package/tests/unit/experiments/fingerprint-parity-harness.test.js +97 -0
- package/tests/unit/fingerprint/apply.test.js +271 -0
- package/tests/unit/fingerprint/automation-parity.test.js +212 -0
- package/tests/unit/fingerprint/cdp-overrides.test.js +227 -0
- package/tests/unit/fingerprint/derive.test.js +105 -0
- package/tests/unit/fingerprint/init-script.test.js +357 -0
- package/tests/unit/fingerprint/limitations.test.js +184 -0
- package/tests/unit/fingerprint/presets.test.js +135 -0
- package/tests/unit/fingerprint/profile.test.js +282 -0
- 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-pipeline-status.test.js +133 -0
- package/tests/unit/scripts/check-release-format.test.js +131 -0
- package/tests/unit/scripts/check-web-archive.test.js +191 -0
- package/tests/unit/scripts/ci-timeout-budgets.test.js +311 -0
- package/tests/unit/scripts/ci-workflow-policy.test.js +292 -153
- 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/debug-print.test.js +67 -0
- package/tests/unit/scripts/jscpd-config.test.js +141 -0
- package/tests/unit/scripts/pre-commit-config.test.js +214 -0
- package/tests/unit/scripts/read-manifest.test.js +194 -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/run-with-budget-warning.test.js +115 -0
- package/tests/unit/scripts/rust-changelog.test.js +147 -0
- package/tests/unit/scripts/use-module-adoption.test.js +74 -0
- package/tests/unit/scripts/use-module-integration.test.js +64 -0
- package/tests/unit/scripts/use-module.test.js +182 -0
- package/.husky/pre-commit +0 -1
- package/.prettierrc +0 -10
- package/deno.json +0 -7
|
@@ -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
|
+
});
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The gate that makes a cancelled job visible.
|
|
3
|
+
*
|
|
4
|
+
* GitHub reports a job killed by its `timeout-minutes` as *cancelled*, not
|
|
5
|
+
* *failed*, and a run whose only casualty is a cancelled job is filed under
|
|
6
|
+
* `cancelled` as well - run 24045269874 of this repository is one such run on
|
|
7
|
+
* `main`. scripts/check-pipeline-status.sh is the only thing that looks at
|
|
8
|
+
* that, so these tests pin the four readings it has to get right: a failure is
|
|
9
|
+
* always an error, a cancellation off the default branch is a warning, a
|
|
10
|
+
* cancellation on the default branch is an error unless a newer run has already
|
|
11
|
+
* taken over the branch head.
|
|
12
|
+
*
|
|
13
|
+
* See docs/CI-TIMEOUT-BUDGETS.md.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import assert from 'node:assert/strict';
|
|
17
|
+
import { mkdtempSync, rmSync } from 'node:fs';
|
|
18
|
+
import { tmpdir } from 'node:os';
|
|
19
|
+
import path from 'node:path';
|
|
20
|
+
import { spawnSync } from 'node:child_process';
|
|
21
|
+
import { describe, it } from 'node:test';
|
|
22
|
+
|
|
23
|
+
import {
|
|
24
|
+
BASH_AVAILABLE,
|
|
25
|
+
REPO_ROOT,
|
|
26
|
+
repoPath,
|
|
27
|
+
runBashScript,
|
|
28
|
+
} from '../../helpers/repo.js';
|
|
29
|
+
|
|
30
|
+
const SCRIPT = repoPath('scripts/check-pipeline-status.sh');
|
|
31
|
+
|
|
32
|
+
function runGate(needs, env = {}, options = {}) {
|
|
33
|
+
return runBashScript(SCRIPT, [], {
|
|
34
|
+
cwd: options.cwd,
|
|
35
|
+
env: { NEEDS_JSON: JSON.stringify(needs), ...env },
|
|
36
|
+
});
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function results(map) {
|
|
40
|
+
return Object.fromEntries(
|
|
41
|
+
Object.entries(map).map(([job, result]) => [job, { result }])
|
|
42
|
+
);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
describe('check-pipeline-status.sh', { skip: !BASH_AVAILABLE }, () => {
|
|
46
|
+
it('passes when every job succeeded or was legitimately skipped', () => {
|
|
47
|
+
const { status, output } = runGate(
|
|
48
|
+
results({ lint: 'success', release: 'skipped' }),
|
|
49
|
+
{ IS_MAIN: 'true', RUN_SHA: 'abc', BRANCH_HEAD_SHA: 'abc' }
|
|
50
|
+
);
|
|
51
|
+
|
|
52
|
+
assert.equal(status, 0);
|
|
53
|
+
assert.match(
|
|
54
|
+
output,
|
|
55
|
+
/All required jobs succeeded or were legitimately skipped/
|
|
56
|
+
);
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it('fails on a failed job and names it', () => {
|
|
60
|
+
const { status, output } = runGate(
|
|
61
|
+
results({ lint: 'failure', test: 'success' }),
|
|
62
|
+
{ IS_MAIN: 'true', RUN_SHA: 'abc', BRANCH_HEAD_SHA: 'abc' }
|
|
63
|
+
);
|
|
64
|
+
|
|
65
|
+
assert.equal(status, 1);
|
|
66
|
+
assert.match(output, /::error::Pipeline failed\. Failing jobs: lint/);
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it('only warns about a cancellation off the default branch', () => {
|
|
70
|
+
const { status, output } = runGate(results({ test: 'cancelled' }), {
|
|
71
|
+
IS_MAIN: 'false',
|
|
72
|
+
RUN_SHA: 'abc',
|
|
73
|
+
BRANCH_HEAD_SHA: 'abc',
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
assert.equal(status, 0);
|
|
77
|
+
assert.match(output, /::warning::Cancelled jobs: test/);
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it('fails on a cancellation on the default branch head', () => {
|
|
81
|
+
const { status, output } = runGate(results({ test: 'cancelled' }), {
|
|
82
|
+
IS_MAIN: 'true',
|
|
83
|
+
RUN_SHA: 'abc',
|
|
84
|
+
BRANCH_HEAD_SHA: 'abc',
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
assert.equal(status, 1);
|
|
88
|
+
assert.match(output, /::error::Pipeline has cancelled jobs on main: test/);
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
it('only warns when a newer commit already superseded the run', () => {
|
|
92
|
+
const { status, output } = runGate(results({ test: 'cancelled' }), {
|
|
93
|
+
IS_MAIN: 'true',
|
|
94
|
+
RUN_SHA: 'abc',
|
|
95
|
+
BRANCH_HEAD_SHA: 'def',
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
assert.equal(status, 0);
|
|
99
|
+
assert.match(output, /This run tests abc; main is at def/);
|
|
100
|
+
assert.match(output, /::warning::Cancelled jobs: test/);
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
it('fails rather than guesses when the branch head cannot be resolved', () => {
|
|
104
|
+
// A missed supersede costs one noisy warning; a missed overrun on main
|
|
105
|
+
// costs a silent failure, so the unresolvable case has to be the loud one.
|
|
106
|
+
const directory = mkdtempSync(path.join(tmpdir(), 'pipeline-status-'));
|
|
107
|
+
|
|
108
|
+
try {
|
|
109
|
+
const { status, output } = runGate(
|
|
110
|
+
results({ test: 'cancelled' }),
|
|
111
|
+
{ IS_MAIN: 'true', RUN_SHA: 'abc', BRANCH_HEAD_SHA: '' },
|
|
112
|
+
{ cwd: directory }
|
|
113
|
+
);
|
|
114
|
+
|
|
115
|
+
assert.equal(status, 1);
|
|
116
|
+
assert.match(output, /assuming it is current/);
|
|
117
|
+
assert.match(output, /::error::Pipeline has cancelled jobs on main/);
|
|
118
|
+
} finally {
|
|
119
|
+
rmSync(directory, { force: true, recursive: true });
|
|
120
|
+
}
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
it('refuses to run without the job results', () => {
|
|
124
|
+
const result = spawnSync('bash', [SCRIPT], {
|
|
125
|
+
cwd: REPO_ROOT,
|
|
126
|
+
encoding: 'utf8',
|
|
127
|
+
env: { ...process.env, NEEDS_JSON: '' },
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
assert.notEqual(result.status, 0);
|
|
131
|
+
assert.match(result.stderr, /NEEDS_JSON is required/);
|
|
132
|
+
});
|
|
133
|
+
});
|
|
@@ -0,0 +1,131 @@
|
|
|
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
|
+
const push = script.indexOf('git push origin main');
|
|
124
|
+
|
|
125
|
+
assert.ok(check > 0 && commit > 0 && push > 0);
|
|
126
|
+
assert.ok(
|
|
127
|
+
check < commit && check < push,
|
|
128
|
+
'a check that runs after the push cannot stop a bad release commit'
|
|
129
|
+
);
|
|
130
|
+
});
|
|
131
|
+
});
|