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.
- package/CHANGELOG.md +28 -0
- package/README.md +4 -2
- package/bunfig.toml +4 -0
- package/eslint.config.mjs +1 -0
- package/package.json +4 -4
- package/src/lib/args-parser.js +9 -0
- package/src/lib/cgroup-memory.js +120 -24
- package/src/lib/command-builder.js +4 -0
- package/src/lib/container-removal.js +4 -5
- package/src/lib/detached-finalize.js +1 -0
- package/src/lib/docker-init-option.js +65 -0
- package/src/lib/docker-inspect-batch.js +176 -0
- package/src/lib/docker-resource-options.js +21 -2
- package/src/lib/docker-runtime-args.js +12 -3
- package/src/lib/execution-control.js +8 -6
- package/src/lib/execution-recovery.js +4 -1
- package/src/lib/execution-resume.js +4 -1
- package/src/lib/execution-store.js +19 -1
- package/src/lib/exit-evidence.js +139 -7
- package/src/lib/output-blocks.js +11 -0
- package/src/lib/resume-resources.js +2 -2
- package/src/lib/screen-isolation.js +72 -42
- package/src/lib/status-formatter.js +34 -130
- package/src/lib/status-probe.js +149 -0
- package/src/lib/store-decode-cache.js +141 -0
- package/src/lib/usage.js +3 -0
- package/test/cargo-lock-version.mjs +121 -0
- package/test/check-rust-coverage.mjs +71 -0
- package/test/check-web-archive.mjs +154 -0
- package/test/ci-workflow-invariants.js +204 -0
- package/test/command-builder-docker-cleanup.js +24 -0
- package/test/command-exit-codes.mjs +91 -0
- package/test/docker-runtime-options.js +2 -0
- package/test/docker-snapshot-safety.js +39 -0
- package/test/execution-resume.js +2 -0
- package/test/fixtures/fake-npm.sh +64 -0
- package/test/fixtures/lychee-report.md +39 -0
- package/test/isolation.js +32 -0
- package/test/javascript-first-ci.js +69 -0
- package/test/links-workflow.mjs +127 -0
- package/test/lint-gate.mjs +56 -0
- package/test/npm-publish.mjs +181 -0
- package/test/recheck-broken-links.mjs +452 -0
- package/test/regression-176.js +2 -0
- package/test/regression-187.js +2 -0
- package/test/regression-191.js +4 -0
- package/test/regression-193.js +2 -0
- package/test/regression-198.js +2 -0
- package/test/regression-207.js +8 -2
- package/test/regression-224.js +2 -0
- package/test/regression-236.js +250 -0
- package/test/regression-238.js +129 -0
- package/test/regression-241.js +254 -0
- package/test/regression-242.js +241 -0
- package/test/regression-243.js +493 -0
- package/test/regression-244.js +471 -0
- package/test/regression-247.js +140 -0
- package/test/release-push-retry.mjs +172 -0
- package/test/run-js-tests.mjs +15 -3
- package/test/setup/private-snapshot-lock.js +17 -0
- package/test/shared-golden.js +29 -2
- package/test/transient-links.mjs +139 -0
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import { describe, expect, it } from 'bun:test';
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
checkCoverage,
|
|
5
|
+
parseTarpaulinCoverage,
|
|
6
|
+
} from '../../scripts/check-rust-coverage.mjs';
|
|
7
|
+
|
|
8
|
+
// Tail of a passing cargo-tarpaulin 0.37.5 run (Rust CI/CD, run 39256972823).
|
|
9
|
+
const PASSING_LOG = `|| src/lib/usage.rs: 0/1
|
|
10
|
+
|| src/lib/user_manager.rs: 48/170
|
|
11
|
+
||
|
|
12
|
+
73.81% coverage, 6296/8530 lines covered
|
|
13
|
+
`;
|
|
14
|
+
|
|
15
|
+
// Tail of the failing run on main (run 39257928573): a test failed, tarpaulin
|
|
16
|
+
// stopped before its summary, and the old step reported
|
|
17
|
+
// "Coverage % is below the 50% threshold" (issue #234).
|
|
18
|
+
const FAILED_TEST_LOG = `failures:
|
|
19
|
+
reservation_failure_after_cleanup_preserves_restart_state
|
|
20
|
+
|
|
21
|
+
test result: FAILED. 5 passed; 1 failed; 0 ignored; 0 measured; 0 filtered out; finished in 3.13s
|
|
22
|
+
|
|
23
|
+
Error: "Test failed during run"
|
|
24
|
+
`;
|
|
25
|
+
|
|
26
|
+
describe('check-rust-coverage', () => {
|
|
27
|
+
it('parses the tarpaulin summary line', () => {
|
|
28
|
+
expect(parseTarpaulinCoverage(PASSING_LOG)).toBe(73.81);
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it('uses the last summary when several are printed', () => {
|
|
32
|
+
const log =
|
|
33
|
+
'10.00% coverage, 1/10 lines covered\r\n60.5% coverage, 6/10 lines covered\r\n';
|
|
34
|
+
expect(parseTarpaulinCoverage(log)).toBe(60.5);
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it('ignores lines that only mention coverage', () => {
|
|
38
|
+
expect(parseTarpaulinCoverage('Coverage: 73.81%\n')).toBeNull();
|
|
39
|
+
expect(parseTarpaulinCoverage('')).toBeNull();
|
|
40
|
+
expect(parseTarpaulinCoverage(undefined)).toBeNull();
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it('passes when coverage meets the threshold', () => {
|
|
44
|
+
const result = checkCoverage(PASSING_LOG, 50);
|
|
45
|
+
expect(result.ok).toBe(true);
|
|
46
|
+
expect(result.message).toBe('✅ Coverage 73.81% meets the 50% threshold');
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it('fails when coverage is below the threshold', () => {
|
|
50
|
+
const result = checkCoverage(PASSING_LOG, 80);
|
|
51
|
+
expect(result.ok).toBe(false);
|
|
52
|
+
expect(result.message).toBe(
|
|
53
|
+
'❌ Coverage 73.81% is below the 80% threshold'
|
|
54
|
+
);
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it('reports a failed test instead of an empty coverage drop (issue #234)', () => {
|
|
58
|
+
const result = checkCoverage(FAILED_TEST_LOG, 50);
|
|
59
|
+
expect(result.ok).toBe(false);
|
|
60
|
+
expect(result.coverage).toBeNull();
|
|
61
|
+
expect(result.message).toContain('a test failed');
|
|
62
|
+
expect(result.message).not.toContain('below the');
|
|
63
|
+
expect(result.message).not.toContain('Coverage %');
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it('fails when the log has no summary for another reason', () => {
|
|
67
|
+
const result = checkCoverage('error: could not compile', 50);
|
|
68
|
+
expect(result.ok).toBe(false);
|
|
69
|
+
expect(result.message).toContain('Could not determine coverage');
|
|
70
|
+
});
|
|
71
|
+
});
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
// Regression tests for the lychee report parser used by the Broken Link
|
|
2
|
+
// Checker workflow. A lychee error without an http(s) URL - a missing local
|
|
3
|
+
// file, an unresolvable root-relative link - has no Wayback equivalent. Such
|
|
4
|
+
// errors must still be reported, and must keep the check red.
|
|
5
|
+
import assert from 'node:assert/strict';
|
|
6
|
+
import { describe, it } from 'bun:test';
|
|
7
|
+
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
|
8
|
+
import { tmpdir } from 'node:os';
|
|
9
|
+
import { spawnSync } from 'node:child_process';
|
|
10
|
+
import { dirname, join } from 'node:path';
|
|
11
|
+
import { fileURLToPath } from 'node:url';
|
|
12
|
+
|
|
13
|
+
import {
|
|
14
|
+
extractBrokenLinks,
|
|
15
|
+
extractBrokenUrls,
|
|
16
|
+
extractErrorsSection,
|
|
17
|
+
} from '../../scripts/check-web-archive.mjs';
|
|
18
|
+
|
|
19
|
+
const here = dirname(fileURLToPath(import.meta.url));
|
|
20
|
+
const report = readFileSync(
|
|
21
|
+
join(here, 'fixtures', 'lychee-report.md'),
|
|
22
|
+
'utf-8'
|
|
23
|
+
);
|
|
24
|
+
|
|
25
|
+
describe('extractErrorsSection', () => {
|
|
26
|
+
it('stops at the next top-level heading', () => {
|
|
27
|
+
const section = extractErrorsSection(report);
|
|
28
|
+
assert.ok(section.includes('Errors in README.md'));
|
|
29
|
+
assert.ok(
|
|
30
|
+
!section.includes('Redirects per input'),
|
|
31
|
+
'the redirects section must not leak into the errors section'
|
|
32
|
+
);
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it('falls back to the whole document when the heading is absent', () => {
|
|
36
|
+
const legacy = '# Link report\n\n* [404] https://broken.example/legacy\n';
|
|
37
|
+
assert.equal(extractErrorsSection(legacy), legacy);
|
|
38
|
+
});
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
describe('extractBrokenUrls', () => {
|
|
42
|
+
it('ignores successful redirects outside the errors section', () => {
|
|
43
|
+
const shortReport = `## Errors per input
|
|
44
|
+
|
|
45
|
+
### Errors in docs/reference.md
|
|
46
|
+
|
|
47
|
+
* [502] <https://broken.example/reference> (at 1:1) | Rejected status code: 502
|
|
48
|
+
|
|
49
|
+
## Redirects per input
|
|
50
|
+
|
|
51
|
+
### Redirects in README.md
|
|
52
|
+
|
|
53
|
+
* https://working.example/old --[301]--> https://working.example/current
|
|
54
|
+
`;
|
|
55
|
+
|
|
56
|
+
assert.deepEqual(extractBrokenUrls(shortReport), [
|
|
57
|
+
'https://broken.example/reference',
|
|
58
|
+
]);
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it('parses the full document when the errors section is absent', () => {
|
|
62
|
+
const legacyReport = `# Link report
|
|
63
|
+
|
|
64
|
+
* [404] https://broken.example/legacy
|
|
65
|
+
`;
|
|
66
|
+
|
|
67
|
+
assert.deepEqual(extractBrokenUrls(legacyReport), [
|
|
68
|
+
'https://broken.example/legacy',
|
|
69
|
+
]);
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it('extracts every http error from a full report exactly once', () => {
|
|
73
|
+
assert.deepEqual(extractBrokenUrls(report), [
|
|
74
|
+
'https://example.com/csharp/',
|
|
75
|
+
'https://example.com/rust/link_cli/',
|
|
76
|
+
]);
|
|
77
|
+
});
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
describe('extractBrokenLinks', () => {
|
|
81
|
+
it('reports errors that the Wayback Machine cannot answer', () => {
|
|
82
|
+
const { others } = extractBrokenLinks(report);
|
|
83
|
+
assert.equal(
|
|
84
|
+
others.length,
|
|
85
|
+
2,
|
|
86
|
+
'the missing DocFX file and the unresolvable root-relative link must not be silently dropped'
|
|
87
|
+
);
|
|
88
|
+
assert.ok(
|
|
89
|
+
others.some((link) => link.endsWith('Foundation.Data.Doublets.Cli.yml')),
|
|
90
|
+
'the missing local file must be reported'
|
|
91
|
+
);
|
|
92
|
+
assert.ok(
|
|
93
|
+
others.includes('error:'),
|
|
94
|
+
'the unresolvable root-relative link must be reported'
|
|
95
|
+
);
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
it('never classifies a non-http error as a URL', () => {
|
|
99
|
+
const localOnly = `## Errors per input
|
|
100
|
+
|
|
101
|
+
### Errors in docs/index.md
|
|
102
|
+
|
|
103
|
+
* [ERROR] <file:///repo/docs/api/Some.Type.yml> (at 15:12) | File not found. Check if file exists and path is correct
|
|
104
|
+
`;
|
|
105
|
+
|
|
106
|
+
const { urls, others } = extractBrokenLinks(localOnly);
|
|
107
|
+
assert.deepEqual(urls, []);
|
|
108
|
+
assert.deepEqual(others, ['file:///repo/docs/api/Some.Type.yml']);
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
it('matches the error count lychee itself reports', () => {
|
|
112
|
+
const { urls, others } = extractBrokenLinks(report);
|
|
113
|
+
const reported = Number(/🚫 Errors\s*\|\s*(\d+)/.exec(report)[1]);
|
|
114
|
+
assert.equal(urls.length + others.length, reported);
|
|
115
|
+
});
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
describe('check-web-archive.mjs end to end', () => {
|
|
119
|
+
it('fails when the only lychee errors have no http URL to archive', async () => {
|
|
120
|
+
const scriptPath = join(
|
|
121
|
+
here,
|
|
122
|
+
'..',
|
|
123
|
+
'..',
|
|
124
|
+
'scripts',
|
|
125
|
+
'check-web-archive.mjs'
|
|
126
|
+
);
|
|
127
|
+
const workDir = mkdtempSync(join(tmpdir(), 'web-archive-'));
|
|
128
|
+
const reportPath = join(workDir, 'out.md');
|
|
129
|
+
const outputPath = join(workDir, 'github-output.txt');
|
|
130
|
+
writeFileSync(
|
|
131
|
+
reportPath,
|
|
132
|
+
`## Errors per input
|
|
133
|
+
|
|
134
|
+
### Errors in docs/index.md
|
|
135
|
+
|
|
136
|
+
* [ERROR] <file:///repo/docs/api/Some.Type.yml> (at 15:12) | File not found. Check if file exists and path is correct
|
|
137
|
+
`
|
|
138
|
+
);
|
|
139
|
+
writeFileSync(outputPath, '');
|
|
140
|
+
|
|
141
|
+
const result = spawnSync(process.execPath, [scriptPath], {
|
|
142
|
+
encoding: 'utf-8',
|
|
143
|
+
env: {
|
|
144
|
+
...process.env,
|
|
145
|
+
LYCHEE_OUTPUT: reportPath,
|
|
146
|
+
GITHUB_OUTPUT: outputPath,
|
|
147
|
+
},
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
assert.equal(result.status, 1, result.stdout + result.stderr);
|
|
151
|
+
assert.match(readFileSync(outputPath, 'utf-8'), /all_archived=false/);
|
|
152
|
+
rmSync(workDir, { recursive: true, force: true });
|
|
153
|
+
});
|
|
154
|
+
});
|
|
@@ -127,6 +127,51 @@ function inRunBlock(workflow, index) {
|
|
|
127
127
|
return false;
|
|
128
128
|
}
|
|
129
129
|
|
|
130
|
+
/** The `needs:` of a job, written inline (`[a, b]`) or as a block list. */
|
|
131
|
+
function parseNeeds(body) {
|
|
132
|
+
const inline = jobKey(body, 'needs');
|
|
133
|
+
if (inline === null) {
|
|
134
|
+
return [];
|
|
135
|
+
}
|
|
136
|
+
if (inline) {
|
|
137
|
+
return inline
|
|
138
|
+
.split(/[,[\]]/)
|
|
139
|
+
.map((entry) => entry.trim())
|
|
140
|
+
.filter(Boolean);
|
|
141
|
+
}
|
|
142
|
+
const block = body.match(/^ {4}needs:\n((?: {6}- .*\n?)+)/m);
|
|
143
|
+
return block
|
|
144
|
+
? block[1]
|
|
145
|
+
.split('\n')
|
|
146
|
+
.map((line) => line.replace(/^ {6}- /, '').trim())
|
|
147
|
+
.filter(Boolean)
|
|
148
|
+
: [];
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/** Jobs the stage's pipeline-status gate passes to `--required`. */
|
|
152
|
+
function requiredStageJobs(jobs) {
|
|
153
|
+
const status = jobs.find((job) => job.name === 'pipeline-status');
|
|
154
|
+
const match = status && status.body.match(/--required ([\w,-]+)/);
|
|
155
|
+
return match ? match[1].split(',') : [];
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/** `workflow_dispatch` inputs as `{ name, body }` blocks. */
|
|
159
|
+
function dispatchInputs(workflow) {
|
|
160
|
+
const section = workflow.match(
|
|
161
|
+
/^ {2}workflow_dispatch:\n {4}inputs:\n((?: {6}.*\n| *\n)+)/m
|
|
162
|
+
);
|
|
163
|
+
if (!section) {
|
|
164
|
+
return [];
|
|
165
|
+
}
|
|
166
|
+
return section[1]
|
|
167
|
+
.split(/^(?= {6}[\w-]+:$)/m)
|
|
168
|
+
.map((block) => {
|
|
169
|
+
const header = block.match(/^ {6}([\w-]+):$/m);
|
|
170
|
+
return header ? { name: header[1], body: block } : null;
|
|
171
|
+
})
|
|
172
|
+
.filter(Boolean);
|
|
173
|
+
}
|
|
174
|
+
|
|
130
175
|
const workflows = listWorkflows();
|
|
131
176
|
|
|
132
177
|
describe('CI workflow invariants', () => {
|
|
@@ -449,6 +494,38 @@ describe('CI workflow invariants', () => {
|
|
|
449
494
|
);
|
|
450
495
|
});
|
|
451
496
|
|
|
497
|
+
it('fails a pull request whose Cargo.lock is out of date', () => {
|
|
498
|
+
// A release bump that rewrote only Cargo.toml left Cargo.lock behind and
|
|
499
|
+
// every later cargo run silently rewrote it (issue #234).
|
|
500
|
+
const lint = parseJobs(readWorkflow('rust.yml')).find(
|
|
501
|
+
(job) => job.name === 'lint'
|
|
502
|
+
);
|
|
503
|
+
assert.ok(lint, 'rust.yml has no lint job');
|
|
504
|
+
assert.match(lint.body, /cargo metadata --locked/);
|
|
505
|
+
});
|
|
506
|
+
|
|
507
|
+
it('reports a failed Rust test as a failed test, not low coverage', () => {
|
|
508
|
+
// Without pipefail a failing test reached the threshold check as an empty
|
|
509
|
+
// value: "Coverage % is below the 50% threshold" (issue #234).
|
|
510
|
+
const coverage = parseJobs(readWorkflow('rust.yml')).find(
|
|
511
|
+
(job) => job.name === 'coverage'
|
|
512
|
+
);
|
|
513
|
+
assert.ok(coverage, 'rust.yml has no coverage job');
|
|
514
|
+
assert.match(coverage.body, /set -o pipefail/);
|
|
515
|
+
assert.match(coverage.body, /scripts\/check-rust-coverage\.mjs/);
|
|
516
|
+
assert.doesNotMatch(
|
|
517
|
+
coverage.body,
|
|
518
|
+
/grep -oP/,
|
|
519
|
+
'parse tarpaulin output through the tested helper'
|
|
520
|
+
);
|
|
521
|
+
assert.ok(
|
|
522
|
+
coverage.body.includes(
|
|
523
|
+
'cargo-tarpaulin-bin-${{ env.TARPAULIN_VERSION }}'
|
|
524
|
+
),
|
|
525
|
+
'an unversioned tarpaulin cache key never picks up a new release'
|
|
526
|
+
);
|
|
527
|
+
});
|
|
528
|
+
|
|
452
529
|
it('parses coverage through the tested helper, not an inline grep', () => {
|
|
453
530
|
const js = readWorkflow('js.yml');
|
|
454
531
|
assert.doesNotMatch(
|
|
@@ -463,4 +540,131 @@ describe('CI workflow invariants', () => {
|
|
|
463
540
|
'an unparsable coverage report must fail, not skip'
|
|
464
541
|
);
|
|
465
542
|
});
|
|
543
|
+
it('releases only after every check its stage requires has passed', () => {
|
|
544
|
+
// Rust Auto Release needed only [lint, test, build] and started in run
|
|
545
|
+
// 39257928573 while the tarpaulin test run was failing (issue #234).
|
|
546
|
+
for (const name of ['js.yml', 'rust.yml']) {
|
|
547
|
+
const jobs = parseJobs(readWorkflow(name));
|
|
548
|
+
const required = requiredStageJobs(jobs);
|
|
549
|
+
assert.ok(required.length > 0, `${name}: no --required list`);
|
|
550
|
+
const releases = jobs.filter(
|
|
551
|
+
(job) => WRITER_JOBS.has(job.name) && job.name !== 'changeset-pr'
|
|
552
|
+
);
|
|
553
|
+
assert.ok(releases.length > 0, `${name}: no release jobs`);
|
|
554
|
+
for (const job of releases) {
|
|
555
|
+
const needs = parseNeeds(job.body);
|
|
556
|
+
for (const check of required) {
|
|
557
|
+
assert.ok(
|
|
558
|
+
needs.includes(check),
|
|
559
|
+
`${name}: ${job.name} does not wait for ${check}`
|
|
560
|
+
);
|
|
561
|
+
}
|
|
562
|
+
// The job-level `needs.*` also sees skipped ancestors (changeset-check
|
|
563
|
+
// and changelog are skipped on push), so a wildcard check skipped
|
|
564
|
+
// every main release; only named results are reliable (issue #240).
|
|
565
|
+
assert.ok(
|
|
566
|
+
!job.body.includes('needs.*'),
|
|
567
|
+
`${name}: ${job.name} checks needs.* instead of named results`
|
|
568
|
+
);
|
|
569
|
+
for (const check of needs) {
|
|
570
|
+
assert.ok(
|
|
571
|
+
job.body.includes(`needs.${check}.result == 'success'`),
|
|
572
|
+
`${name}: ${job.name} may start without a successful ${check}`
|
|
573
|
+
);
|
|
574
|
+
}
|
|
575
|
+
}
|
|
576
|
+
}
|
|
577
|
+
});
|
|
578
|
+
|
|
579
|
+
it('fails a main run whose release was skipped although its needs succeeded', () => {
|
|
580
|
+
// Nothing was published for days while every run looked green (#240).
|
|
581
|
+
for (const [name, release] of [
|
|
582
|
+
['js.yml', 'release'],
|
|
583
|
+
['rust.yml', 'auto-release'],
|
|
584
|
+
]) {
|
|
585
|
+
const jobs = parseJobs(readWorkflow(name));
|
|
586
|
+
const job = jobs.find((entry) => entry.name === release);
|
|
587
|
+
const gate = jobs.find((entry) => entry.name === 'pipeline-status');
|
|
588
|
+
assert.ok(
|
|
589
|
+
gate.body.includes(
|
|
590
|
+
`--release '${release}:${parseNeeds(job.body).join(',')}'`
|
|
591
|
+
),
|
|
592
|
+
`${name}: pipeline-status does not check that ${release} ran`
|
|
593
|
+
);
|
|
594
|
+
}
|
|
595
|
+
});
|
|
596
|
+
|
|
597
|
+
it('gives every required dispatch input a default', () => {
|
|
598
|
+
// `gh workflow run` cannot fill a form; a required input without a
|
|
599
|
+
// default makes the dispatch API answer HTTP 422 (issue #234).
|
|
600
|
+
for (const name of workflows) {
|
|
601
|
+
for (const input of dispatchInputs(readWorkflow(name))) {
|
|
602
|
+
if (/^ {8}required: true$/m.test(input.body)) {
|
|
603
|
+
assert.match(
|
|
604
|
+
input.body,
|
|
605
|
+
/^ {8}default: /m,
|
|
606
|
+
`${name}: dispatch input "${input.name}" has no default`
|
|
607
|
+
);
|
|
608
|
+
}
|
|
609
|
+
}
|
|
610
|
+
}
|
|
611
|
+
});
|
|
612
|
+
it('opens release pull requests with a token that triggers their checks', () => {
|
|
613
|
+
// A pull request opened with the built-in GITHUB_TOKEN starts no
|
|
614
|
+
// pull_request workflows, so the changeset PR never ran its checks
|
|
615
|
+
// (issue #234; template issue #192).
|
|
616
|
+
for (const name of workflows) {
|
|
617
|
+
for (const { name: key, body } of parseJobs(readWorkflow(name))) {
|
|
618
|
+
if (!body.includes('peter-evans/create-pull-request@')) {
|
|
619
|
+
continue;
|
|
620
|
+
}
|
|
621
|
+
assert.ok(
|
|
622
|
+
!/^ {10}token: \$\{\{ secrets\.GITHUB_TOKEN \}\}/m.test(body),
|
|
623
|
+
`${name}: ${key} opens its pull request with GITHUB_TOKEN`
|
|
624
|
+
);
|
|
625
|
+
assert.ok(
|
|
626
|
+
/^ {10}token: \$\{\{ secrets\.RELEASE_PR_TOKEN \}\}/m.test(body) &&
|
|
627
|
+
body.includes('::error::RELEASE_PR_TOKEN is required'),
|
|
628
|
+
`${name}: ${key} must use RELEASE_PR_TOKEN and fail loudly without it`
|
|
629
|
+
);
|
|
630
|
+
}
|
|
631
|
+
}
|
|
632
|
+
});
|
|
633
|
+
|
|
634
|
+
it('re-runs a path-filtered workflow when a script it runs changes', () => {
|
|
635
|
+
// workflows.yml ran only on `.github/**`, so a broken
|
|
636
|
+
// scripts/install-actionlint.sh merged without the linter running.
|
|
637
|
+
for (const name of workflows) {
|
|
638
|
+
const workflow = readWorkflow(name);
|
|
639
|
+
const filters = [
|
|
640
|
+
...workflow.matchAll(/^ {4}paths:\n((?: {6}- .*\n)+)/gm),
|
|
641
|
+
].map((match) =>
|
|
642
|
+
match[1]
|
|
643
|
+
.split('\n')
|
|
644
|
+
.map((line) =>
|
|
645
|
+
line
|
|
646
|
+
.replace(/^ {6}- /, '')
|
|
647
|
+
.replace(/'/g, '')
|
|
648
|
+
.trim()
|
|
649
|
+
)
|
|
650
|
+
.filter(Boolean)
|
|
651
|
+
);
|
|
652
|
+
if (filters.length === 0) {
|
|
653
|
+
continue;
|
|
654
|
+
}
|
|
655
|
+
const scripts = new Set(
|
|
656
|
+
[...workflow.matchAll(/\bscripts\/[\w.-]+\.(?:sh|mjs|js)\b/g)].map(
|
|
657
|
+
(match) => match[0]
|
|
658
|
+
)
|
|
659
|
+
);
|
|
660
|
+
for (const paths of filters) {
|
|
661
|
+
for (const script of scripts) {
|
|
662
|
+
assert.ok(
|
|
663
|
+
paths.includes(script) || paths.includes('scripts/**'),
|
|
664
|
+
`${name}: ${script} changes do not trigger the workflow`
|
|
665
|
+
);
|
|
666
|
+
}
|
|
667
|
+
}
|
|
668
|
+
}
|
|
669
|
+
});
|
|
466
670
|
});
|
|
@@ -32,4 +32,28 @@ describe('command-builder docker cleanup forwarding', () => {
|
|
|
32
32
|
assert.match(command, /--isolated "tmux"/);
|
|
33
33
|
assert.doesNotMatch(command, /--keep-container/);
|
|
34
34
|
});
|
|
35
|
+
|
|
36
|
+
it('forwards --init only to a remaining docker level (issue #244)', () => {
|
|
37
|
+
const nested = buildNextLevelCommand(
|
|
38
|
+
{
|
|
39
|
+
isolatedStack: ['screen', 'docker'],
|
|
40
|
+
imageStack: [null, 'alpine'],
|
|
41
|
+
init: true,
|
|
42
|
+
},
|
|
43
|
+
'npm test'
|
|
44
|
+
);
|
|
45
|
+
assert.match(nested, /--isolated "docker"/);
|
|
46
|
+
assert.match(nested, / --init(?: |$)/);
|
|
47
|
+
|
|
48
|
+
const consumed = buildNextLevelCommand(
|
|
49
|
+
{
|
|
50
|
+
isolatedStack: ['docker', 'tmux'],
|
|
51
|
+
imageStack: ['alpine', null],
|
|
52
|
+
init: true,
|
|
53
|
+
},
|
|
54
|
+
'npm test'
|
|
55
|
+
);
|
|
56
|
+
assert.match(consumed, /--isolated "tmux"/);
|
|
57
|
+
assert.doesNotMatch(consumed, /--init/);
|
|
58
|
+
});
|
|
35
59
|
});
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import { describe, expect, it } from 'bun:test';
|
|
2
|
+
import { readdirSync, readFileSync } from 'fs';
|
|
3
|
+
import { dirname, join } from 'path';
|
|
4
|
+
import { fileURLToPath } from 'url';
|
|
5
|
+
|
|
6
|
+
// command-stream's `$` resolves with a non-zero `code` instead of throwing, so
|
|
7
|
+
// a bare `await $`...`` - or one inside try/catch - silently ignores a failed
|
|
8
|
+
// command. That made a rejected `npm publish` print "✅ Published" and a failed
|
|
9
|
+
// GitHub API call print "✅ Formatted release notes" (issue #234). Every call in
|
|
10
|
+
// scripts/ must either go through checked() or read the result's `.code`.
|
|
11
|
+
|
|
12
|
+
const scriptsDir = join(
|
|
13
|
+
dirname(fileURLToPath(import.meta.url)),
|
|
14
|
+
'../../scripts'
|
|
15
|
+
);
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Find `$` template calls whose exit code nobody looks at.
|
|
19
|
+
* @param {string} source
|
|
20
|
+
* @returns {string[]} offending lines, prefixed with their line number
|
|
21
|
+
*/
|
|
22
|
+
export function findUncheckedCommands(source) {
|
|
23
|
+
const lines = source.split('\n');
|
|
24
|
+
const offenders = [];
|
|
25
|
+
lines.forEach((line, index) => {
|
|
26
|
+
const trimmed = line.trim();
|
|
27
|
+
if (
|
|
28
|
+
!trimmed.includes('$`') ||
|
|
29
|
+
trimmed.startsWith('*') ||
|
|
30
|
+
trimmed.startsWith('//')
|
|
31
|
+
) {
|
|
32
|
+
return;
|
|
33
|
+
}
|
|
34
|
+
const previous = index > 0 ? lines[index - 1] : '';
|
|
35
|
+
if (/\bchecked\(/.test(line) || /\bchecked\(\s*$/.test(previous)) {
|
|
36
|
+
return;
|
|
37
|
+
}
|
|
38
|
+
const assigned = `${previous}\n${line}`.match(
|
|
39
|
+
/(?:const|let)\s+(\w+)\s*=\s*(?:await\s+)?$|(?:const|let)\s+(\w+)\s*=\s*(?:await\s+)?\$`/m
|
|
40
|
+
);
|
|
41
|
+
const name = assigned && (assigned[1] || assigned[2]);
|
|
42
|
+
const rest = lines.slice(index + 1).join('\n');
|
|
43
|
+
if (name && new RegExp(`\\b${name}\\.code\\b`).test(rest)) {
|
|
44
|
+
return;
|
|
45
|
+
}
|
|
46
|
+
offenders.push(`${index + 1}: ${trimmed}`);
|
|
47
|
+
});
|
|
48
|
+
return offenders;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
describe('command exit codes in release scripts', () => {
|
|
52
|
+
it('flags a bare call and a try/catch-wrapped call', () => {
|
|
53
|
+
const source = [
|
|
54
|
+
'try {',
|
|
55
|
+
' await $`npm run changeset:publish`;',
|
|
56
|
+
'} catch {}',
|
|
57
|
+
'const r = await $`gh api x`.run({ capture: true });',
|
|
58
|
+
'console.log(r.stdout);',
|
|
59
|
+
].join('\n');
|
|
60
|
+
expect(findUncheckedCommands(source)).toEqual([
|
|
61
|
+
'2: await $`npm run changeset:publish`;',
|
|
62
|
+
'4: const r = await $`gh api x`.run({ capture: true });',
|
|
63
|
+
]);
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it('accepts checked() and an inspected .code', () => {
|
|
67
|
+
const source = [
|
|
68
|
+
'await checked($`git push`, "git push");',
|
|
69
|
+
'await checked(',
|
|
70
|
+
' $`git fetch`,',
|
|
71
|
+
' "git fetch"',
|
|
72
|
+
');',
|
|
73
|
+
'const ancestry =',
|
|
74
|
+
' await $`git merge-base --is-ancestor a b`.run({ capture: true });',
|
|
75
|
+
'if (ancestry.code !== 0) throw new Error("x");',
|
|
76
|
+
'// await $`ignored in a comment`',
|
|
77
|
+
].join('\n');
|
|
78
|
+
expect(findUncheckedCommands(source)).toEqual([]);
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it('every command in scripts/*.mjs has its exit code checked', () => {
|
|
82
|
+
const offenders = readdirSync(scriptsDir)
|
|
83
|
+
.filter((file) => file.endsWith('.mjs'))
|
|
84
|
+
.flatMap((file) =>
|
|
85
|
+
findUncheckedCommands(readFileSync(join(scriptsDir, file), 'utf8')).map(
|
|
86
|
+
(offender) => `scripts/${file}:${offender}`
|
|
87
|
+
)
|
|
88
|
+
);
|
|
89
|
+
expect(offenders).toEqual([]);
|
|
90
|
+
});
|
|
91
|
+
});
|
|
@@ -303,6 +303,7 @@ describe('buildDockerRuntimeMetadata', () => {
|
|
|
303
303
|
labels: null,
|
|
304
304
|
commandHandoff: null,
|
|
305
305
|
privileged: null,
|
|
306
|
+
init: null,
|
|
306
307
|
network: null,
|
|
307
308
|
networks: null,
|
|
308
309
|
networkAliases: null,
|
|
@@ -332,6 +333,7 @@ describe('buildDockerRuntimeMetadata', () => {
|
|
|
332
333
|
mounts: ['type=bind,src=/h,dst=/c'],
|
|
333
334
|
env: ['FOO=bar'],
|
|
334
335
|
privileged: true,
|
|
336
|
+
init: null,
|
|
335
337
|
network: 'hive-formal-ai',
|
|
336
338
|
networks: ['hive-formal-ai', 'public-egress'],
|
|
337
339
|
networkAliases: ['formal-ai', 'checker'],
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
// Own snapshot lock even without the bunfig.toml preload (issue #234).
|
|
2
|
+
require('./setup/private-snapshot-lock');
|
|
1
3
|
const { test } = require('node:test');
|
|
2
4
|
const assert = require('node:assert/strict');
|
|
3
5
|
const fs = require('node:fs');
|
|
@@ -104,3 +106,40 @@ test('snapshot lock is separate from store locks and excludes concurrent process
|
|
|
104
106
|
fs.rmSync(dir, { recursive: true, force: true });
|
|
105
107
|
}
|
|
106
108
|
});
|
|
109
|
+
|
|
110
|
+
test('tests never share the host-wide snapshot lock (issue #234)', () => {
|
|
111
|
+
// bunfig.toml preloads test/setup/private-snapshot-lock.js. Without it a
|
|
112
|
+
// real `start --resume` holding the default lock failed every resume test.
|
|
113
|
+
const lockPath = process.env.START_SNAPSHOT_LOCK;
|
|
114
|
+
assert.ok(lockPath, 'START_SNAPSHOT_LOCK is not set by the test preload');
|
|
115
|
+
assert.notEqual(
|
|
116
|
+
lockPath,
|
|
117
|
+
path.join(
|
|
118
|
+
process.platform === 'win32' ? os.tmpdir() : '/tmp',
|
|
119
|
+
'start-command-docker-snapshot.lock'
|
|
120
|
+
)
|
|
121
|
+
);
|
|
122
|
+
const lock = acquireSnapshotLock({ holder: 'issue-234' });
|
|
123
|
+
try {
|
|
124
|
+
assert.ok(fs.existsSync(lockPath));
|
|
125
|
+
} finally {
|
|
126
|
+
lock.release();
|
|
127
|
+
}
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
test('every snapshot test requires its private lock itself (issue #234)', () => {
|
|
131
|
+
// `bun test ./js/test/x.js` from the repository root skips js/bunfig.toml,
|
|
132
|
+
// so the preload alone left 18 tests on the host-wide lock.
|
|
133
|
+
const dir = __dirname;
|
|
134
|
+
const offenders = fs
|
|
135
|
+
.readdirSync(dir)
|
|
136
|
+
.filter((name) => name.endsWith('.js'))
|
|
137
|
+
.filter((name) => {
|
|
138
|
+
const source = fs.readFileSync(path.join(dir, name), 'utf8');
|
|
139
|
+
return (
|
|
140
|
+
/\b(resumeExecution|acquireSnapshotLock)\(/.test(source) &&
|
|
141
|
+
!source.includes("require('./setup/private-snapshot-lock')")
|
|
142
|
+
);
|
|
143
|
+
});
|
|
144
|
+
assert.deepEqual(offenders, []);
|
|
145
|
+
});
|
package/test/execution-resume.js
CHANGED
|
@@ -2,6 +2,8 @@
|
|
|
2
2
|
* Tests for --resume and --resume-all (issue #162)
|
|
3
3
|
*/
|
|
4
4
|
|
|
5
|
+
// Own snapshot lock even without the bunfig.toml preload (issue #234).
|
|
6
|
+
require('./setup/private-snapshot-lock');
|
|
5
7
|
const { test, describe } = require('node:test');
|
|
6
8
|
const assert = require('node:assert');
|
|
7
9
|
const {
|