start-command 0.38.0 → 0.39.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.
Files changed (62) hide show
  1. package/CHANGELOG.md +28 -0
  2. package/README.md +4 -2
  3. package/bunfig.toml +4 -0
  4. package/eslint.config.mjs +1 -0
  5. package/package.json +4 -4
  6. package/src/lib/args-parser.js +9 -0
  7. package/src/lib/cgroup-memory.js +120 -24
  8. package/src/lib/command-builder.js +4 -0
  9. package/src/lib/container-removal.js +4 -5
  10. package/src/lib/detached-finalize.js +1 -0
  11. package/src/lib/docker-init-option.js +65 -0
  12. package/src/lib/docker-inspect-batch.js +176 -0
  13. package/src/lib/docker-resource-options.js +21 -2
  14. package/src/lib/docker-runtime-args.js +12 -3
  15. package/src/lib/execution-control.js +8 -6
  16. package/src/lib/execution-recovery.js +4 -1
  17. package/src/lib/execution-resume.js +4 -1
  18. package/src/lib/execution-store.js +19 -1
  19. package/src/lib/exit-evidence.js +139 -7
  20. package/src/lib/output-blocks.js +11 -0
  21. package/src/lib/resume-resources.js +2 -2
  22. package/src/lib/screen-isolation.js +72 -42
  23. package/src/lib/status-formatter.js +34 -130
  24. package/src/lib/status-probe.js +149 -0
  25. package/src/lib/store-decode-cache.js +141 -0
  26. package/src/lib/usage.js +3 -0
  27. package/test/cargo-lock-version.mjs +121 -0
  28. package/test/check-rust-coverage.mjs +71 -0
  29. package/test/check-web-archive.mjs +154 -0
  30. package/test/ci-workflow-invariants.js +204 -0
  31. package/test/command-builder-docker-cleanup.js +24 -0
  32. package/test/command-exit-codes.mjs +91 -0
  33. package/test/docker-runtime-options.js +2 -0
  34. package/test/docker-snapshot-safety.js +39 -0
  35. package/test/execution-resume.js +2 -0
  36. package/test/fixtures/fake-npm.sh +64 -0
  37. package/test/fixtures/lychee-report.md +39 -0
  38. package/test/isolation.js +32 -0
  39. package/test/javascript-first-ci.js +69 -0
  40. package/test/links-workflow.mjs +127 -0
  41. package/test/lint-gate.mjs +56 -0
  42. package/test/npm-publish.mjs +181 -0
  43. package/test/recheck-broken-links.mjs +452 -0
  44. package/test/regression-176.js +2 -0
  45. package/test/regression-187.js +2 -0
  46. package/test/regression-191.js +4 -0
  47. package/test/regression-193.js +2 -0
  48. package/test/regression-198.js +2 -0
  49. package/test/regression-207.js +8 -2
  50. package/test/regression-224.js +2 -0
  51. package/test/regression-236.js +250 -0
  52. package/test/regression-238.js +129 -0
  53. package/test/regression-241.js +254 -0
  54. package/test/regression-242.js +241 -0
  55. package/test/regression-243.js +493 -0
  56. package/test/regression-244.js +471 -0
  57. package/test/regression-247.js +140 -0
  58. package/test/release-push-retry.mjs +172 -0
  59. package/test/run-js-tests.mjs +15 -3
  60. package/test/setup/private-snapshot-lock.js +17 -0
  61. package/test/shared-golden.js +29 -2
  62. package/test/transient-links.mjs +139 -0
@@ -0,0 +1,172 @@
1
+ import { describe, expect, it } from 'bun:test';
2
+ import { execFileSync } from 'node:child_process';
3
+ import {
4
+ chmodSync,
5
+ existsSync,
6
+ mkdtempSync,
7
+ rmSync,
8
+ writeFileSync,
9
+ } from 'node:fs';
10
+ import { tmpdir } from 'node:os';
11
+ import { join } from 'node:path';
12
+
13
+ import { $ } from 'command-stream';
14
+
15
+ import {
16
+ checked,
17
+ classifyPushRejection,
18
+ pushWithRetry,
19
+ } from '../../scripts/release-git.mjs';
20
+
21
+ function git(cwd, ...args) {
22
+ return execFileSync('git', args, { cwd, stdio: 'pipe' }).toString().trim();
23
+ }
24
+
25
+ function clone(dir, remote, name) {
26
+ const work = join(dir, name);
27
+ git(dir, 'clone', '-q', remote, work);
28
+ git(work, 'config', 'user.name', name);
29
+ git(work, 'config', 'user.email', `${name}@example.com`);
30
+ return work;
31
+ }
32
+
33
+ /**
34
+ * Two writers on one bare `main`: `other` lands first, then `ours` pushes a
35
+ * commit made on the stale tip - what happens when the main-writer
36
+ * concurrency group runs two release jobs back to back (#234).
37
+ */
38
+ async function withWriters({ otherFile, ourFile, rule = false }, callback) {
39
+ const dir = mkdtempSync(join(tmpdir(), 'release-push-'));
40
+ const previousCwd = process.cwd();
41
+ try {
42
+ const remote = join(dir, 'remote.git');
43
+ git(dir, 'init', '-q', '--bare', '-b', 'main', remote);
44
+ const seed = clone(dir, remote, 'seed');
45
+ writeFileSync(join(seed, 'package.json'), '{"version":"0.1.0"}\n');
46
+ git(seed, 'add', '.');
47
+ git(seed, 'commit', '-q', '-m', 'initial');
48
+ git(seed, 'push', '-q', 'origin', 'main');
49
+
50
+ const ours = clone(dir, remote, 'ours');
51
+ const other = clone(dir, remote, 'other');
52
+ writeFileSync(join(other, otherFile), 'other writer\n');
53
+ git(other, 'add', '.');
54
+ git(other, 'commit', '-q', '-m', 'other release');
55
+ git(other, 'push', '-q', 'origin', 'main');
56
+
57
+ writeFileSync(join(ours, ourFile), 'our release\n');
58
+ git(ours, 'add', '.');
59
+ git(ours, 'commit', '-q', '-m', 'our release');
60
+
61
+ const pushes = join(dir, 'pushes');
62
+ const hook = join(remote, 'hooks', 'pre-receive');
63
+ writeFileSync(
64
+ hook,
65
+ rule
66
+ ? `#!/bin/sh\necho x >> '${pushes}'\necho "error: GH013: Repository rule violations found for refs/heads/main." >&2\nexit 1\n`
67
+ : `#!/bin/sh\necho x >> '${pushes}'\n`
68
+ );
69
+ chmodSync(hook, 0o755);
70
+ process.chdir(ours);
71
+ return await callback({ ours, remote, pushes });
72
+ } finally {
73
+ process.chdir(previousCwd);
74
+ rmSync(dir, { recursive: true, force: true });
75
+ }
76
+ }
77
+
78
+ const noSleep = () => Promise.resolve();
79
+
80
+ describe('pushWithRetry (#234)', () => {
81
+ it('reproduces the bug: a writer queued behind another is rejected', async () => {
82
+ await withWriters(
83
+ { otherFile: 'Cargo.toml', ourFile: 'package.json' },
84
+ async () => {
85
+ await expect(
86
+ checked(
87
+ $`git push origin main`.run({ capture: true, mirror: false }),
88
+ 'git push origin main'
89
+ )
90
+ ).rejects.toThrow('git push origin main failed with exit code 1');
91
+ }
92
+ );
93
+ });
94
+
95
+ it('rebases onto the writer that landed first and pushes', async () => {
96
+ await withWriters(
97
+ { otherFile: 'Cargo.toml', ourFile: 'package.json' },
98
+ async ({ ours, remote }) => {
99
+ expect(await pushWithRetry($, { sleep: noSleep })).toEqual({
100
+ attempt: 2,
101
+ });
102
+ expect(git(remote, 'log', '--format=%s', 'main')).toBe(
103
+ 'our release\nother release\ninitial'
104
+ );
105
+ expect(git(ours, 'status', '--porcelain')).toBe('');
106
+ }
107
+ );
108
+ });
109
+
110
+ it('fails loudly when the other writer changed the same files', async () => {
111
+ await withWriters(
112
+ { otherFile: 'package.json', ourFile: 'package.json' },
113
+ async ({ remote }) => {
114
+ await expect(pushWithRetry($, { sleep: noSleep })).rejects.toThrow(
115
+ 'git pull --rebase origin main'
116
+ );
117
+ expect(git(remote, 'log', '-1', '--format=%s', 'main')).toBe(
118
+ 'other release'
119
+ );
120
+ }
121
+ );
122
+ });
123
+
124
+ it('does not retry a push a repository rule rejected', async () => {
125
+ await withWriters(
126
+ { otherFile: 'Cargo.toml', ourFile: 'package.json', rule: true },
127
+ async ({ pushes }) => {
128
+ // Land on the tip first so only the rule can reject the push.
129
+ await checked(
130
+ $`git pull --rebase origin main`.run({
131
+ capture: true,
132
+ mirror: false,
133
+ }),
134
+ 'git pull --rebase'
135
+ );
136
+ await expect(pushWithRetry($, { sleep: noSleep })).rejects.toThrow(
137
+ 'blocked by a repository rule'
138
+ );
139
+ expect(existsSync(pushes)).toBe(true);
140
+ expect(execFileSync('wc', ['-l', pushes]).toString()).toMatch(/^\s*1 /);
141
+ }
142
+ );
143
+ });
144
+
145
+ it('classifies real git push output', () => {
146
+ expect(
147
+ classifyPushRejection(
148
+ ' ! [rejected] main -> main (fetch first)\nerror: failed to push some refs'
149
+ )
150
+ ).toBe('non-fast-forward');
151
+ expect(
152
+ classifyPushRejection(
153
+ ' ! [rejected] main -> main (non-fast-forward)'
154
+ )
155
+ ).toBe('non-fast-forward');
156
+ expect(
157
+ classifyPushRejection(
158
+ 'remote: error: GH013: Repository rule violations found for refs/heads/main.\n ! [remote rejected] main -> main (push declined due to repository rule violations)'
159
+ )
160
+ ).toBe('rule');
161
+ expect(
162
+ classifyPushRejection(
163
+ 'remote: error: GH006: Protected branch update failed for refs/heads/main.'
164
+ )
165
+ ).toBe('rule');
166
+ expect(
167
+ classifyPushRejection(
168
+ "fatal: unable to access 'https://github.com/x/y/': Could not resolve host"
169
+ )
170
+ ).toBe('other');
171
+ });
172
+ });
@@ -28,8 +28,20 @@ describe('run-js-tests timeout defaults', () => {
28
28
  ]);
29
29
  });
30
30
 
31
- test('preserves Bun short timeout flag', () => {
32
- expect(hasExplicitTimeout(['-t', '45000'])).toBe(true);
33
- expect(applyDefaultTimeout(['-t', '45000'])).toEqual(['-t', '45000']);
31
+ // `bun test -t` is --test-name-pattern, not a timeout (#234).
32
+ test('keeps the default timeout for a -t name pattern', () => {
33
+ expect(hasExplicitTimeout(['-t', 'slow'])).toBe(false);
34
+ expect(applyDefaultTimeout(['-t', 'slow'])).toEqual([
35
+ '--timeout',
36
+ '30000',
37
+ '-t',
38
+ 'slow',
39
+ ]);
40
+ expect(applyDefaultTimeout(['--test-name-pattern', 'slow'])).toEqual([
41
+ '--timeout',
42
+ '30000',
43
+ '--test-name-pattern',
44
+ 'slow',
45
+ ]);
34
46
  });
35
47
  });
@@ -0,0 +1,17 @@
1
+ /**
2
+ * Test preload (bunfig.toml): give this test process its own Docker snapshot
3
+ * lock. The default lock is host-wide, so a real `start --resume` or another
4
+ * test process on the same host holding it made every snapshot-resume test
5
+ * fail after the 1s default wait (issue #234). A path set by the caller wins,
6
+ * so experiments can still point the tests at a shared lock.
7
+ */
8
+ const { mkdtempSync } = require('fs');
9
+ const { tmpdir } = require('os');
10
+ const { join } = require('path');
11
+
12
+ if (!process.env.START_SNAPSHOT_LOCK) {
13
+ process.env.START_SNAPSHOT_LOCK = join(
14
+ mkdtempSync(join(tmpdir(), 'start-test-snapshot-lock-')),
15
+ 'snapshot.lock'
16
+ );
17
+ }
@@ -37,18 +37,45 @@ describe('shared JavaScript and Rust golden contracts', () => {
37
37
  );
38
38
  });
39
39
  }
40
+ // The fixtures describe the defaults, so a host-wide docker init default
41
+ // (issue #244) must not leak into them.
42
+ function parseWithoutHostDefaults(argv) {
43
+ const saved = process.env.START_COMMAND_DOCKER_INIT;
44
+ delete process.env.START_COMMAND_DOCKER_INIT;
45
+ try {
46
+ return parseArgs(argv);
47
+ } finally {
48
+ if (saved !== undefined) {
49
+ process.env.START_COMMAND_DOCKER_INIT = saved;
50
+ }
51
+ }
52
+ }
40
53
  for (const fixture of fixtures['cli-parsing']) {
41
54
  it(`CLI parsing: ${fixture.name}`, () => {
42
55
  if (fixture.error) {
43
- assert.throws(() => parseArgs(fixture.argv), new RegExp(fixture.error));
56
+ assert.throws(
57
+ () => parseWithoutHostDefaults(fixture.argv),
58
+ new RegExp(fixture.error)
59
+ );
44
60
  return;
45
61
  }
46
- const parsed = parseArgs(fixture.argv);
62
+ const parsed = parseWithoutHostDefaults(fixture.argv);
47
63
  assert.equal(parsed.command, fixture.command);
48
64
  assert.deepEqual(parsed.rawCommand, fixture.rawCommand);
49
65
  for (const [key, value] of Object.entries(fixture.options)) {
50
66
  assert.deepEqual(parsed.wrapperOptions[key], value, key);
51
67
  }
68
+ if (fixture.dockerResourceLimits) {
69
+ const {
70
+ resolveResourceOptions,
71
+ } = require('../src/lib/docker-resource-options');
72
+ assert.deepEqual(
73
+ buildDockerRuntimeArgs(
74
+ resolveResourceOptions(parsed.wrapperOptions)
75
+ ).sort(),
76
+ [...fixture.dockerResourceLimits].sort()
77
+ );
78
+ }
52
79
  });
53
80
  }
54
81
  for (const fixture of fixtures['isolation-arguments']) {
@@ -0,0 +1,139 @@
1
+ import { readFileSync } from 'node:fs';
2
+ import path from 'node:path';
3
+ import { fileURLToPath } from 'node:url';
4
+ import { describe, expect, it } from 'bun:test';
5
+ import {
6
+ parseLycheeFailures,
7
+ recheckUnanswered,
8
+ } from '../../scripts/recheck-broken-links.mjs';
9
+
10
+ const root = path.resolve(
11
+ path.dirname(fileURLToPath(import.meta.url)),
12
+ '../..'
13
+ );
14
+
15
+ const url = 'https://github.com/owner/repo/blob/main/src/index.js';
16
+ const base = { accept: '200..=299', userAgent: 'lychee' };
17
+
18
+ describe('transient link failures', () => {
19
+ it('classifies cached 503 and rejected 429 as retryable, preserving 404', () => {
20
+ const failures = parseLycheeFailures(
21
+ [
22
+ `- [503] <${url}> | Error (cached)`,
23
+ `- [ERROR] <${url}> | Rejected status code: 429 Too Many Requests`,
24
+ `- [404] <${url}> | Not Found`,
25
+ ].join('\n')
26
+ );
27
+ expect(failures.map((item) => item.retryable)).toEqual([true, true, false]);
28
+ });
29
+
30
+ it('backs off between transient answers and recovers only on success', async () => {
31
+ let now = 0;
32
+ const waits = [];
33
+ const statuses = [503, 429, 200];
34
+ const result = await recheckUnanswered([url], {
35
+ ...base,
36
+ nowFn: () => now,
37
+ sleepFn: async (ms) => {
38
+ waits.push(ms);
39
+ now += ms;
40
+ },
41
+ fetchImpl: async () => ({ status: statuses.shift() }),
42
+ });
43
+ expect(result.recovered).toEqual([url]);
44
+ expect(waits).toEqual([5000, 10000]);
45
+ });
46
+
47
+ it('uses authenticated contents GET for GitHub blob and tree throttling', async () => {
48
+ for (const kind of ['blob', 'tree']) {
49
+ const page = `https://github.com/owner/repo/${kind}/release%2Fv1/src/a%20b.js`;
50
+ const calls = [];
51
+ const result = await recheckUnanswered([page], {
52
+ ...base,
53
+ githubToken: 'test-token',
54
+ fetchImpl: async (target, init) => {
55
+ calls.push({ target, init });
56
+ return { status: calls.length === 1 ? 503 : 200 };
57
+ },
58
+ });
59
+ expect(result.recovered).toEqual([page]);
60
+ expect(calls[0].init.headers.authorization).toBe(undefined);
61
+ expect(calls[1].target).toBe(
62
+ 'https://api.github.com/repos/owner/repo/contents/src/a%20b.js?ref=release%2Fv1'
63
+ );
64
+ expect(calls[1].init.method).toBe('GET');
65
+ expect(calls[1].init.headers.authorization).toBe('Bearer test-token');
66
+ expect(calls[1].init.redirect).toBe('error');
67
+ }
68
+ });
69
+
70
+ it('does not recover on an API denial and keeps credentials off unrelated hosts', async () => {
71
+ let now = 0;
72
+ const calls = [];
73
+ const result = await recheckUnanswered(
74
+ [url, 'https://other.example/page'],
75
+ {
76
+ ...base,
77
+ githubToken: 'test-token',
78
+ budgetSeconds: 1,
79
+ nowFn: () => now,
80
+ fetchImpl: async (target, init) => {
81
+ calls.push({ target, init });
82
+ now += 100;
83
+ return {
84
+ status: target.startsWith('https://api.github.com/') ? 401 : 503,
85
+ };
86
+ },
87
+ }
88
+ );
89
+ expect(result.recovered).toEqual([]);
90
+ expect(result.stillBroken.length).toBe(2);
91
+ const other = calls.find((call) =>
92
+ call.target.startsWith('https://other.example/')
93
+ );
94
+ expect(other.init.headers.authorization).toBe(undefined);
95
+ });
96
+
97
+ it('never retries a definitive 404 or uses an API answer to hide it', async () => {
98
+ let calls = 0;
99
+ const result = await recheckUnanswered([url], {
100
+ ...base,
101
+ githubToken: 'test-token',
102
+ fetchImpl: async () => {
103
+ calls++;
104
+ return { status: 404 };
105
+ },
106
+ });
107
+ expect(calls).toBe(1);
108
+ expect(result.recovered).toEqual([]);
109
+ });
110
+
111
+ it('bounds persistent 503 errors by the shared wall-clock budget', async () => {
112
+ let now = 0;
113
+ let calls = 0;
114
+ const result = await recheckUnanswered([url], {
115
+ ...base,
116
+ budgetSeconds: 16,
117
+ nowFn: () => now,
118
+ sleepFn: async (ms) => {
119
+ now += ms;
120
+ },
121
+ fetchImpl: async () => {
122
+ calls++;
123
+ return { status: 503 };
124
+ },
125
+ });
126
+ expect(calls).toBe(3);
127
+ expect(result.recovered).toEqual([]);
128
+ expect(result.stillBroken.length).toBe(1);
129
+ });
130
+
131
+ it('pins every Bun setup to the supported major line', () => {
132
+ const workflow = readFileSync(
133
+ path.join(root, '.github/workflows/js.yml'),
134
+ 'utf8'
135
+ );
136
+ expect(workflow).not.toContain('bun-version: latest');
137
+ expect(workflow).toContain("bun-version: '1.x'");
138
+ });
139
+ });