browser-commander 0.17.1 → 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.
@@ -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,
@@ -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,
@@ -98,6 +100,7 @@
98
100
  "5ae9b1430fc38daf": 1,
99
101
  "602bfd129bba900e": 1,
100
102
  "608e1dd27138ec6f": 1,
103
+ "6098544c77ff17a2": 1,
101
104
  "60d0c58a01fc8746": 1,
102
105
  "61bcadcd8dc9f63e": 1,
103
106
  "63a3b672c8d8b50b": 1,
@@ -142,7 +145,6 @@
142
145
  "876d2c8edfc8c40a": 1,
143
146
  "8b013ac20e436d76": 1,
144
147
  "8c2b1408886c1db1": 1,
145
- "8c36ed4fc291ae47": 1,
146
148
  "8cb9a984bf19a062": 1,
147
149
  "8ec18cdbd4a96518": 1,
148
150
  "8ecf2e554a04d2ef": 1,
package/CHANGELOG.md CHANGED
@@ -1,5 +1,19 @@
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
+
3
17
  ## 0.17.1
4
18
 
5
19
  ### Patch Changes
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "browser-commander",
3
- "version": "0.17.1",
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",
@@ -16,6 +16,10 @@ import { readFileSync, appendFileSync, readdirSync } from 'fs';
16
16
 
17
17
  import { assertGeneratedFilesAreFormatted } from './check-release-format.mjs';
18
18
 
19
+ import {
20
+ createGitRunner,
21
+ pushWithRebaseRetry,
22
+ } from '../../scripts/push-with-rebase-retry.mjs';
19
23
  import {
20
24
  loadCommandStream,
21
25
  loadLinoArguments,
@@ -232,8 +236,15 @@ async function main() {
232
236
  const escapedMessage = commitMessage.replace(/"/g, '\\"');
233
237
  await $`git commit -m "${escapedMessage}"`;
234
238
 
235
- // Push directly to main
236
- 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' });
237
248
 
238
249
  console.log('\u2705 Version bump committed and pushed to main');
239
250
  setOutput('version_committed', 'true');
@@ -120,7 +120,10 @@ describe('release commit formatting gate', () => {
120
120
  );
121
121
  const check = script.indexOf('assertGeneratedFilesAreFormatted(() =>');
122
122
  const commit = script.indexOf('git commit');
123
- const push = script.indexOf('git push origin main');
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({');
124
127
 
125
128
  assert.ok(check > 0 && commit > 0 && push > 0);
126
129
  assert.ok(
@@ -0,0 +1,153 @@
1
+ /**
2
+ * Guard for RC-1: a release job that treats every rejected push the same.
3
+ *
4
+ * Two rejections both print "rejected" and need opposite responses. A lost
5
+ * race is fixed by rebasing; a repository-rule violation can never be, and
6
+ * retrying it burns the retry and blames a race that never happened.
7
+ *
8
+ * The samples below are the literal wordings the two situations produce:
9
+ * `non-fast-forward` from the CI logs in dev/log/issues/85/pulls/86/ci-logs/,
10
+ * `fetch first` from the local reproduction in
11
+ * experiments/ci-repro/repro-release-push-race.sh.
12
+ *
13
+ * Analysis: dev/log/issues/85/pulls/86/analysis/root-causes.md, RC-1
14
+ */
15
+
16
+ import assert from 'node:assert/strict';
17
+ import { describe, it } from 'node:test';
18
+
19
+ import {
20
+ NON_FAST_FORWARD_PATTERNS,
21
+ REPOSITORY_RULE_PATTERNS,
22
+ classifyPushFailure,
23
+ isBlockedByRepositoryRule,
24
+ isNonFastForward,
25
+ } from '../../../../scripts/push-failure-classifier.mjs';
26
+
27
+ /** Rejection text a lost race produces, verbatim. */
28
+ const RACE_SAMPLES = [
29
+ {
30
+ label: 'non-fast-forward (remote commits already fetched)',
31
+ stderr: [
32
+ 'To https://github.com/link-foundation/browser-commander',
33
+ ' ! [rejected] HEAD -> main (non-fast-forward)',
34
+ "error: failed to push some refs to 'https://github.com/link-foundation/browser-commander'",
35
+ 'hint: Updates were rejected because a pushed branch tip is behind its remote',
36
+ ].join('\n'),
37
+ },
38
+ {
39
+ label: 'fetch first (remote commits not yet fetched)',
40
+ stderr: [
41
+ ' ! [rejected] HEAD -> main (fetch first)',
42
+ 'error: failed to push some refs',
43
+ 'hint: Updates were rejected because the remote contains work that you do not',
44
+ 'hint: have locally.',
45
+ ].join('\n'),
46
+ },
47
+ ];
48
+
49
+ /** Rejection text a ruleset or protected branch produces, verbatim. */
50
+ const RULE_SAMPLES = [
51
+ {
52
+ label: 'GH013 repository ruleset',
53
+ stderr: [
54
+ 'remote: error: GH013: Repository rule violations found for refs/heads/main.',
55
+ 'remote: - Changes must be made through a pull request.',
56
+ ' ! [remote rejected] main -> main (push declined due to repository rule violations)',
57
+ ].join('\n'),
58
+ },
59
+ {
60
+ label: 'GH006 legacy protected branch',
61
+ stderr: [
62
+ 'remote: error: GH006: Protected branch update failed for refs/heads/main.',
63
+ ' ! [remote rejected] main -> main (protected branch hook declined)',
64
+ ].join('\n'),
65
+ },
66
+ ];
67
+
68
+ describe('push failure classifier', () => {
69
+ it('reads a lost race as non-fast-forward', () => {
70
+ for (const sample of RACE_SAMPLES) {
71
+ assert.equal(
72
+ classifyPushFailure(sample),
73
+ 'non-fast-forward',
74
+ `${sample.label} must be retried by rebasing`
75
+ );
76
+ assert.equal(isNonFastForward(sample), true, sample.label);
77
+ assert.equal(isBlockedByRepositoryRule(sample), false, sample.label);
78
+ }
79
+ });
80
+
81
+ it('never reads a rule violation as a lost race', () => {
82
+ // This is the whole point of the module. A ruleset rejection contains the
83
+ // word "rejected" too, so a naive substring check retries a push that can
84
+ // never succeed.
85
+ for (const sample of RULE_SAMPLES) {
86
+ assert.equal(
87
+ classifyPushFailure(sample),
88
+ 'repository-rule',
89
+ `${sample.label} must not be retried`
90
+ );
91
+ assert.equal(isNonFastForward(sample), false, sample.label);
92
+ assert.equal(isBlockedByRepositoryRule(sample), true, sample.label);
93
+ }
94
+ });
95
+
96
+ it('leaves an unrelated failure unclassified so it is not retried', () => {
97
+ const authFailure = {
98
+ stderr:
99
+ 'fatal: could not read Username for https://github.com: No such device or address',
100
+ };
101
+ assert.equal(classifyPushFailure(authFailure), 'unknown');
102
+ assert.equal(isNonFastForward(authFailure), false);
103
+ assert.equal(isBlockedByRepositoryRule(authFailure), false);
104
+ });
105
+
106
+ it('ignores the generic wrapper message command-stream attaches', () => {
107
+ // command-stream rejects with "Command failed with exit code 1"; the
108
+ // classification has to rest on what git wrote, not on the wrapper.
109
+ const wrapped = {
110
+ message: 'Command failed with exit code 1',
111
+ stdout: '',
112
+ stderr: 'Everything up-to-date',
113
+ };
114
+ assert.equal(classifyPushFailure(wrapped), 'unknown');
115
+ });
116
+
117
+ it('reads a bare string as well as a command result', () => {
118
+ assert.equal(
119
+ classifyPushFailure(' ! [rejected] main -> main (non-fast-forward)'),
120
+ 'non-fast-forward'
121
+ );
122
+ assert.equal(
123
+ classifyPushFailure('remote: error: GH013:'),
124
+ 'repository-rule'
125
+ );
126
+ });
127
+
128
+ it('survives a failure with nothing to read', () => {
129
+ for (const empty of [null, undefined, {}, '']) {
130
+ assert.equal(classifyPushFailure(empty), 'unknown');
131
+ }
132
+ });
133
+
134
+ it('matches case-insensitively, because git and GitHub disagree on case', () => {
135
+ assert.equal(
136
+ classifyPushFailure({
137
+ stderr: 'REMOTE: ERROR: GH013: REPOSITORY RULE VIOLATIONS',
138
+ }),
139
+ 'repository-rule'
140
+ );
141
+ assert.equal(
142
+ classifyPushFailure({
143
+ stderr: '! [REJECTED] main -> main (NON-FAST-FORWARD)',
144
+ }),
145
+ 'non-fast-forward'
146
+ );
147
+ });
148
+
149
+ it('keeps the pattern lists frozen so a caller cannot widen them', () => {
150
+ assert.throws(() => REPOSITORY_RULE_PATTERNS.push('anything'));
151
+ assert.throws(() => NON_FAST_FORWARD_PATTERNS.push('anything'));
152
+ });
153
+ });
@@ -0,0 +1,302 @@
1
+ /**
2
+ * Guard for RC-1 and RC-3.
3
+ *
4
+ * RC-1: the release jobs are serialised by a concurrency group but each one
5
+ * checks out `github.sha`, so every writer after the first holds a tree one
6
+ * commit behind main and its push is rejected. Nothing rebased and retried.
7
+ *
8
+ * RC-3: a retry rebases, which rewrites the release commit. A tag created
9
+ * before the push would keep pointing at the pre-rebase commit and be
10
+ * reachable from no branch -- reproduced as case 2 of
11
+ * experiments/ci-repro/repro-release-push-race.sh. So the release scripts must
12
+ * tag only after the push has succeeded.
13
+ *
14
+ * Analysis: dev/log/issues/85/pulls/86/analysis/root-causes.md
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
+ import {
24
+ DEFAULT_ATTEMPTS,
25
+ pushWithRebaseRetry,
26
+ resolveCurrentBranch,
27
+ } from '../../../../scripts/push-with-rebase-retry.mjs';
28
+
29
+ const repoRoot = join(dirname(fileURLToPath(import.meta.url)), '../../../..');
30
+
31
+ /** A rejection shaped like the one `command-stream` produces for git. */
32
+ function rejection(stderr) {
33
+ return Object.assign(new Error('Command failed with exit code 1'), {
34
+ code: 1,
35
+ stdout: '',
36
+ stderr,
37
+ });
38
+ }
39
+
40
+ const RACE = ' ! [rejected] HEAD -> main (non-fast-forward)\n';
41
+ const RULE = [
42
+ 'remote: error: GH013: Repository rule violations found for refs/heads/main.',
43
+ 'remote: - Changes must be made through a pull request.',
44
+ ].join('\n');
45
+
46
+ /**
47
+ * Build a fake git that fails the first `failures` pushes with `stderr`, then
48
+ * succeeds, recording every argv it was handed.
49
+ */
50
+ function fakeGit({ failures = 0, stderr = RACE, stdout = '' } = {}) {
51
+ const calls = [];
52
+ let pushes = 0;
53
+ const git = async (args) => {
54
+ calls.push(args.join(' '));
55
+ if (args[0] === 'push') {
56
+ pushes++;
57
+ if (pushes <= failures) {
58
+ throw rejection(stderr);
59
+ }
60
+ }
61
+ return { stdout, stderr: '', code: 0 };
62
+ };
63
+ git.calls = calls;
64
+ return git;
65
+ }
66
+
67
+ /** Collect log lines instead of printing them. */
68
+ function fakeLogger() {
69
+ const lines = [];
70
+ return { lines, log: (line) => lines.push(line) };
71
+ }
72
+
73
+ const silent = () => {};
74
+
75
+ describe('pushWithRebaseRetry', () => {
76
+ it('pushes once when nothing else has written to the branch', async () => {
77
+ const git = fakeGit();
78
+ const result = await pushWithRebaseRetry({
79
+ git,
80
+ branch: 'main',
81
+ logger: fakeLogger(),
82
+ debugFn: silent,
83
+ });
84
+
85
+ assert.deepEqual(result, { pushed: true, via: 'direct', attempts: 1 });
86
+ assert.deepEqual(git.calls, ['push origin HEAD:main']);
87
+ });
88
+
89
+ it('rebases and retries after losing the race, as in run 33998729944', async () => {
90
+ const git = fakeGit({ failures: 1 });
91
+ const logger = fakeLogger();
92
+
93
+ const result = await pushWithRebaseRetry({
94
+ git,
95
+ branch: 'main',
96
+ logger,
97
+ debugFn: silent,
98
+ });
99
+
100
+ assert.deepEqual(result, { pushed: true, via: 'rebase', attempts: 2 });
101
+ assert.deepEqual(git.calls, [
102
+ 'push origin HEAD:main',
103
+ 'pull --rebase origin main',
104
+ 'push origin HEAD:main',
105
+ ]);
106
+ assert.ok(
107
+ logger.lines.some((line) => line.startsWith('::warning::')),
108
+ 'a recovered race must still be visible in the log'
109
+ );
110
+ });
111
+
112
+ it('does not retry a repository-rule rejection', async () => {
113
+ // Rebasing cannot satisfy a ruleset. Retrying it doubles the time the job
114
+ // takes to die and leaves a log blaming a race that never happened.
115
+ const git = fakeGit({ failures: DEFAULT_ATTEMPTS, stderr: RULE });
116
+ const logger = fakeLogger();
117
+
118
+ await assert.rejects(
119
+ pushWithRebaseRetry({ git, branch: 'main', logger, debugFn: silent })
120
+ );
121
+
122
+ assert.deepEqual(git.calls, ['push origin HEAD:main']);
123
+ assert.ok(
124
+ logger.lines.some(
125
+ (line) => line.startsWith('::error::') && line.includes('GH006/GH013')
126
+ ),
127
+ 'the log must name the real cause'
128
+ );
129
+ });
130
+
131
+ it('does not retry a failure a rebase cannot fix', async () => {
132
+ const git = fakeGit({
133
+ failures: DEFAULT_ATTEMPTS,
134
+ stderr: 'fatal: could not read Username for https://github.com',
135
+ });
136
+
137
+ await assert.rejects(
138
+ pushWithRebaseRetry({
139
+ git,
140
+ branch: 'main',
141
+ logger: fakeLogger(),
142
+ debugFn: silent,
143
+ })
144
+ );
145
+ assert.deepEqual(git.calls, ['push origin HEAD:main']);
146
+ });
147
+
148
+ it('gives up after the configured number of attempts', async () => {
149
+ const git = fakeGit({ failures: 99 });
150
+
151
+ await assert.rejects(
152
+ pushWithRebaseRetry({
153
+ git,
154
+ branch: 'main',
155
+ attempts: 3,
156
+ logger: fakeLogger(),
157
+ debugFn: silent,
158
+ })
159
+ );
160
+
161
+ const pushes = git.calls.filter((call) => call.startsWith('push'));
162
+ assert.equal(pushes.length, 3, 'exactly `attempts` pushes, no more');
163
+ });
164
+
165
+ it('pushes the branch it is told to, not always main', async () => {
166
+ const git = fakeGit();
167
+ await pushWithRebaseRetry({
168
+ git,
169
+ remote: 'upstream',
170
+ branch: 'release',
171
+ logger: fakeLogger(),
172
+ debugFn: silent,
173
+ });
174
+ assert.deepEqual(git.calls, ['push upstream HEAD:release']);
175
+ });
176
+
177
+ it('stays silent on the debug channel unless tracing is switched on', async () => {
178
+ // The verbose mode has to default to off: a release log is read by people
179
+ // looking for the one line that matters.
180
+ const traced = [];
181
+ const git = fakeGit({ failures: 1 });
182
+ await pushWithRebaseRetry({
183
+ git,
184
+ branch: 'main',
185
+ logger: fakeLogger(),
186
+ debugFn: (...parts) => traced.push(parts.join(' ')),
187
+ });
188
+ assert.ok(traced.length > 0, 'the helper must offer tracing to inject');
189
+
190
+ const quiet = [];
191
+ const { debugWith } = await import('../../../../scripts/debug-print.mjs');
192
+ await pushWithRebaseRetry({
193
+ git: fakeGit(),
194
+ branch: 'main',
195
+ logger: fakeLogger(),
196
+ debugFn: (...parts) =>
197
+ debugWith({ env: {}, log: (l) => quiet.push(l) }, ...parts),
198
+ });
199
+ assert.deepEqual(quiet, [], 'debug output must be off by default');
200
+ });
201
+ });
202
+
203
+ describe('resolveCurrentBranch', () => {
204
+ it('reads the branch instead of assuming main', async () => {
205
+ // The Rust release used a bare `git push`, which follows the current
206
+ // branch's upstream; a hard-coded HEAD:main would change behaviour for a
207
+ // workflow_dispatch run started from another branch.
208
+ const git = fakeGit({ stdout: 'release-2.x\n' });
209
+ assert.equal(await resolveCurrentBranch(git), 'release-2.x');
210
+ assert.deepEqual(git.calls, ['rev-parse --abbrev-ref HEAD']);
211
+ });
212
+
213
+ it('falls back to main when git says nothing', async () => {
214
+ assert.equal(
215
+ await resolveCurrentBranch(fakeGit({ stdout: ' \n' })),
216
+ 'main'
217
+ );
218
+ });
219
+
220
+ it('does not push HEAD:HEAD from a detached checkout', async () => {
221
+ // `git rev-parse --abbrev-ref HEAD` prints the literal "HEAD" when
222
+ // detached. Using it as the target branch would aim the push at a remote
223
+ // branch named HEAD instead of the release branch.
224
+ assert.equal(
225
+ await resolveCurrentBranch(fakeGit({ stdout: 'HEAD\n' })),
226
+ 'main'
227
+ );
228
+ assert.equal(
229
+ await resolveCurrentBranch(fakeGit({ stdout: 'HEAD\n' }), 'release'),
230
+ 'release'
231
+ );
232
+ });
233
+ });
234
+
235
+ describe('release scripts tag only after the push (RC-3)', () => {
236
+ const releaseScripts = [
237
+ 'rust/scripts/version-and-commit.mjs',
238
+ 'js/scripts/version-and-commit.mjs',
239
+ ];
240
+
241
+ for (const relativePath of releaseScripts) {
242
+ it(`${relativePath} never leaves an orphaned tag`, () => {
243
+ const source = readFileSync(join(repoRoot, relativePath), 'utf8');
244
+ const tagIndex = source.indexOf('git tag -a');
245
+ if (tagIndex === -1) {
246
+ return; // This script does not tag; nothing to order.
247
+ }
248
+ const pushIndex = source.indexOf('pushWithRebaseRetry({');
249
+ assert.notEqual(
250
+ pushIndex,
251
+ -1,
252
+ `${relativePath} must push through pushWithRebaseRetry`
253
+ );
254
+ assert.ok(
255
+ pushIndex < tagIndex,
256
+ `${relativePath} creates a tag before pushing; a rebase retry would ` +
257
+ 'rewrite the commit and orphan that tag'
258
+ );
259
+ });
260
+ }
261
+
262
+ it('no release script pushes with the blanket --tags', () => {
263
+ // `git push --tags` also pushes every unrelated local tag the runner
264
+ // happens to have fetched.
265
+ for (const relativePath of releaseScripts) {
266
+ const source = readFileSync(join(repoRoot, relativePath), 'utf8');
267
+ assert.ok(
268
+ !source.includes('git push --tags'),
269
+ `${relativePath} must push its release tag by name`
270
+ );
271
+ }
272
+ });
273
+ });
274
+
275
+ describe('every release push site uses the shared helper', () => {
276
+ // The fix has to be applied everywhere: a single un-migrated push site
277
+ // reproduces the original failure the next time two releases run.
278
+ it('leaves no raw push to a shared branch behind', () => {
279
+ const sites = [
280
+ 'js/scripts/version-and-commit.mjs',
281
+ 'rust/scripts/version-and-commit.mjs',
282
+ ];
283
+ for (const relativePath of sites) {
284
+ const source = readFileSync(join(repoRoot, relativePath), 'utf8');
285
+ for (const raw of [
286
+ 'git push`',
287
+ 'git push origin main`',
288
+ 'git push --tags`',
289
+ 'git push origin HEAD:main`',
290
+ ]) {
291
+ assert.ok(
292
+ !source.includes(raw),
293
+ `${relativePath} still contains a raw \`${raw}\``
294
+ );
295
+ }
296
+ assert.ok(
297
+ source.includes('pushWithRebaseRetry'),
298
+ `${relativePath} must push through the shared helper`
299
+ );
300
+ }
301
+ });
302
+ });