start-command 0.38.0 → 0.39.0

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 (59) hide show
  1. package/CHANGELOG.md +20 -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 +95 -23
  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/status-formatter.js +34 -130
  23. package/src/lib/status-probe.js +149 -0
  24. package/src/lib/store-decode-cache.js +141 -0
  25. package/src/lib/usage.js +3 -0
  26. package/test/cargo-lock-version.mjs +121 -0
  27. package/test/check-rust-coverage.mjs +71 -0
  28. package/test/check-web-archive.mjs +154 -0
  29. package/test/ci-workflow-invariants.js +204 -0
  30. package/test/command-builder-docker-cleanup.js +24 -0
  31. package/test/command-exit-codes.mjs +91 -0
  32. package/test/docker-runtime-options.js +2 -0
  33. package/test/docker-snapshot-safety.js +39 -0
  34. package/test/execution-resume.js +2 -0
  35. package/test/fixtures/fake-npm.sh +64 -0
  36. package/test/fixtures/lychee-report.md +39 -0
  37. package/test/javascript-first-ci.js +69 -0
  38. package/test/links-workflow.mjs +127 -0
  39. package/test/lint-gate.mjs +56 -0
  40. package/test/npm-publish.mjs +181 -0
  41. package/test/recheck-broken-links.mjs +452 -0
  42. package/test/regression-176.js +2 -0
  43. package/test/regression-187.js +2 -0
  44. package/test/regression-191.js +4 -0
  45. package/test/regression-193.js +2 -0
  46. package/test/regression-198.js +2 -0
  47. package/test/regression-207.js +8 -2
  48. package/test/regression-224.js +2 -0
  49. package/test/regression-236.js +250 -0
  50. package/test/regression-238.js +129 -0
  51. package/test/regression-241.js +254 -0
  52. package/test/regression-242.js +241 -0
  53. package/test/regression-243.js +493 -0
  54. package/test/regression-244.js +471 -0
  55. package/test/release-push-retry.mjs +172 -0
  56. package/test/run-js-tests.mjs +15 -3
  57. package/test/setup/private-snapshot-lock.js +17 -0
  58. package/test/shared-golden.js +29 -2
  59. package/test/transient-links.mjs +139 -0
@@ -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
+ });
@@ -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 {
@@ -0,0 +1,64 @@
1
+ #!/bin/sh
2
+ # Fake `npm` for js/test/npm-publish.mjs (issue #234). State lives in
3
+ # $FAKE_NPM_STATE: `published` marks the version as on the registry,
4
+ # `mode` picks how `npm run changeset:publish` behaves, `calls` logs argv.
5
+ # `pending` holds how many more `npm view` calls miss a version that has
6
+ # landed but not yet propagated to the registry's read path.
7
+ # Any other command goes to $REAL_NPM when set (use-m runs `npm root -g`).
8
+ state="${FAKE_NPM_STATE:?}"
9
+ echo "$*" >> "$state/calls"
10
+ case "$1" in
11
+ view)
12
+ if [ -f "$state/outage" ]; then
13
+ echo "npm error code ECONNRESET" >&2
14
+ exit 1
15
+ fi
16
+ if [ -f "$state/pending" ]; then
17
+ left="$(cat "$state/pending")"
18
+ if [ "$left" -le 1 ]; then
19
+ rm "$state/pending"
20
+ echo "$FAKE_NPM_VERSION" > "$state/published"
21
+ else
22
+ echo $((left - 1)) > "$state/pending"
23
+ fi
24
+ echo "npm error code E404" >&2
25
+ exit 1
26
+ fi
27
+ if [ -f "$state/published" ]; then
28
+ cat "$state/published"
29
+ exit 0
30
+ fi
31
+ echo "npm error code E404" >&2
32
+ exit 1
33
+ ;;
34
+ run)
35
+ mode="$(cat "$state/mode" 2>/dev/null || echo succeed)"
36
+ case "$mode" in
37
+ succeed) echo "$FAKE_NPM_VERSION" > "$state/published"; exit 0 ;;
38
+ fail) echo "npm error code E403" >&2; exit 1 ;;
39
+ exit-0-without-publishing) exit 0 ;;
40
+ fail-after-landing) echo "$FAKE_NPM_VERSION" > "$state/published"; exit 1 ;;
41
+ land-late) echo 20 > "$state/pending"; exit 0 ;;
42
+ time-out-while-landing)
43
+ if [ -f "$state/pending" ] || [ -f "$state/published" ]; then
44
+ echo "npm error code E403" >&2
45
+ echo "npm error You cannot publish over the previously published versions: $FAKE_NPM_VERSION." >&2
46
+ exit 1
47
+ fi
48
+ echo 20 > "$state/pending"
49
+ echo "npm error code ETIMEDOUT" >&2
50
+ exit 1
51
+ ;;
52
+ fail-once)
53
+ echo succeed > "$state/mode"
54
+ echo "npm error code E503" >&2
55
+ exit 1
56
+ ;;
57
+ esac
58
+ ;;
59
+ esac
60
+ if [ -n "${REAL_NPM:-}" ]; then
61
+ exec "$REAL_NPM" "$@"
62
+ fi
63
+ echo "fake npm: unexpected arguments: $*" >&2
64
+ exit 2
@@ -0,0 +1,39 @@
1
+ # Link Checker Report
2
+
3
+ | Status | Count |
4
+ | -------------- | ----- |
5
+ | 🔍 Total | 120 |
6
+ | ✅ Successful | 108 |
7
+ | ⏳ Timeouts | 0 |
8
+ | 🔀 Redirected | 6 |
9
+ | 👻 Excluded | 2 |
10
+ | ❓ Unknown | 0 |
11
+ | 🚫 Errors | 4 |
12
+ | ⛔ Unsupported | 0 |
13
+
14
+ ## Errors per input
15
+
16
+ ### Errors in csharp/docs/index.md
17
+
18
+ - [ERROR] <file:///home/runner/work/repo/repo/csharp/docs/api/Foundation.Data.Doublets.Cli.yml> (at 15:12) | File not found. Check if file exists and path is correct
19
+
20
+ ### Errors in js/index.html
21
+
22
+ - [ERROR] <error:> (at 10:49) | Cannot resolve root-relative link '/favicon.svg': To resolve root-relative links in local files, provide a root dir
23
+
24
+ ### Errors in README.md
25
+
26
+ - [404] <https://example.com/csharp/> (at 48:130) | Rejected status code: 404 Not Found
27
+ - [404] <https://example.com/rust/link_cli/> (at 49:62) | Rejected status code: 404 Not Found
28
+
29
+ ## Redirects per input
30
+
31
+ ### Redirects in README.md
32
+
33
+ - https://example.com/docs --[302]--> https://example.com/docs/latest/
34
+ - https://example.com/protocols --[301]--> https://example.com/links-notation
35
+ - https://example.com/articles/804617 --[301]--> https://example.com/articles/804617/ --[302]--> https://example.com/companies/articles/804617/
36
+
37
+ ### Redirects in rust/README.md
38
+
39
+ - https://example.com/docs --[302]--> https://example.com/docs/latest/
@@ -9,6 +9,7 @@ const {
9
9
  } = require('../../scripts/check-javascript-first.mjs');
10
10
  const {
11
11
  checkFeatureChanges,
12
+ checkRegressionPairs,
12
13
  } = require('../../scripts/check-feature-parity.mjs');
13
14
 
14
15
  const root = resolve(__dirname, '../..');
@@ -178,6 +179,44 @@ describe('JavaScript first CI gate', () => {
178
179
  assert.equal(check.status, 0);
179
180
  });
180
181
 
182
+ const releaseCheck = (isMain, release, test = 'success') =>
183
+ spawnSync(
184
+ process.execPath,
185
+ [
186
+ resolve(root, 'scripts/check-stage-status.mjs'),
187
+ '--required',
188
+ 'test',
189
+ '--release',
190
+ 'release:test',
191
+ ],
192
+ {
193
+ encoding: 'utf8',
194
+ env: {
195
+ ...process.env,
196
+ IS_MAIN: String(isMain),
197
+ NEEDS_JSON: JSON.stringify({
198
+ test: { result: test },
199
+ release: { result: release },
200
+ }),
201
+ },
202
+ }
203
+ );
204
+
205
+ it('fails a main push whose release was skipped after its needs passed', () => {
206
+ // Every main release was silently skipped since #234 (issue #240).
207
+ const check = releaseCheck(true, 'skipped');
208
+ assert.equal(check.status, 1);
209
+ assert.match(check.stderr, /release was skipped on a main push/);
210
+ // The terms of the release condition are printed for the next skip.
211
+ assert.match(check.stdout, /needs\.test\.result=success/);
212
+ assert.match(check.stdout, /release: skipped; expected to run: true/);
213
+ });
214
+
215
+ it('accepts a skipped release outside main pushes and a run release on main', () => {
216
+ assert.equal(releaseCheck(false, 'skipped').status, 0);
217
+ assert.equal(releaseCheck(true, 'success').status, 0);
218
+ });
219
+
181
220
  it('rejects a change to only one implementation of a feature', () => {
182
221
  const features = [
183
222
  {
@@ -194,6 +233,36 @@ describe('JavaScript first CI gate', () => {
194
233
  );
195
234
  });
196
235
 
236
+ it('keeps both halves of a regression test under one feature', () => {
237
+ const feature = (id, js, rust) => ({
238
+ id,
239
+ javascript: { implementation: [], tests: js },
240
+ rust: { implementation: [], tests: rust },
241
+ });
242
+ assert.equal(
243
+ checkRegressionPairs([
244
+ feature('a', ['js/test/regression-193.js'], []),
245
+ feature('b', [], ['rust/tests/regression_193.rs']),
246
+ ]).length,
247
+ 1
248
+ );
249
+ assert.deepEqual(
250
+ checkRegressionPairs([
251
+ feature(
252
+ 'a',
253
+ ['js/test/regression-201.js'],
254
+ ['rust/tests/regression_201.rs']
255
+ ),
256
+ feature('b', [], ['rust/tests/regression_201.rs']),
257
+ ]),
258
+ []
259
+ );
260
+ const manifest = JSON.parse(
261
+ readFileSync(resolve(root, 'parity/features.json'), 'utf8')
262
+ );
263
+ assert.deepEqual(checkRegressionPairs(manifest.features), []);
264
+ });
265
+
197
266
  it('makes Rust callable only after the complete JavaScript stage', () => {
198
267
  const files = workflows();
199
268
  assert.deepEqual(Object.keys(files['rust.yml'].on), ['workflow_call']);