vouchington-tooling 0.0.4 → 0.0.6
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/README.md +10 -0
- package/dist/cli/commands/allocate-browser-safe-ports.slot-fixtures.test-helpers.d.mts +6 -0
- package/dist/cli/commands/allocate-browser-safe-ports.slot-fixtures.test-helpers.mjs +103 -0
- package/dist/cli/commands/github-output.test-helpers.d.mts +1 -0
- package/dist/cli/commands/github-output.test-helpers.mjs +32 -0
- package/dist/cli/commands/pnpm-install.d.mts +1 -0
- package/dist/cli/commands/pnpm-install.mjs +27 -0
- package/dist/cli/commands/spawn-script.d.mts +5 -0
- package/dist/cli/commands/spawn-script.mjs +7 -0
- package/dist/cli/commands/vitest-blob-manifest.d.mts +1 -0
- package/dist/cli/commands/vitest-blob-manifest.mjs +11 -0
- package/dist/cli/index.mjs +28 -0
- package/dist/cli/parse.d.mts +11 -0
- package/dist/cli/parse.mjs +14 -0
- package/dist/cli/script-path.d.mts +1 -0
- package/dist/cli/script-path.mjs +4 -0
- package/dist/cli/usage.d.mts +1 -1
- package/dist/cli/usage.mjs +16 -1
- package/dist/index.d.mts +6 -0
- package/dist/index.mjs +3 -0
- package/dist/pnpm-install/exec.d.mts +8 -0
- package/dist/pnpm-install/exec.mjs +88 -0
- package/dist/pnpm-install/index.d.mts +7 -0
- package/dist/pnpm-install/index.mjs +6 -0
- package/dist/pnpm-install/metadata.d.mts +5 -0
- package/dist/pnpm-install/metadata.mjs +91 -0
- package/dist/pnpm-install/pnpm-install-fixture.test-helpers.d.mts +22 -0
- package/dist/pnpm-install/pnpm-install-fixture.test-helpers.mjs +174 -0
- package/dist/pnpm-install/process.d.mts +11 -0
- package/dist/pnpm-install/process.mjs +81 -0
- package/dist/pnpm-install/release-age.d.mts +9 -0
- package/dist/pnpm-install/release-age.mjs +59 -0
- package/dist/pnpm-install/runner.d.mts +2 -0
- package/dist/pnpm-install/runner.mjs +106 -0
- package/dist/pnpm-install/support.d.mts +30 -0
- package/dist/pnpm-install/support.mjs +143 -0
- package/dist/shared-context/fake-git.test-helpers.d.mts +11 -0
- package/dist/shared-context/fake-git.test-helpers.mjs +59 -0
- package/dist/shared-context/index.d.mts +26 -0
- package/dist/shared-context/index.mjs +89 -0
- package/dist/vitest-blob-manifest/cli.d.mts +2 -0
- package/dist/vitest-blob-manifest/cli.mjs +33 -0
- package/dist/vitest-blob-manifest/index.d.mts +37 -0
- package/dist/vitest-blob-manifest/index.mjs +134 -0
- package/package.json +16 -1
- package/scripts/allocate-browser-safe-ports.py +661 -0
- package/scripts/fetch-forbidden-ports.json +7 -0
- package/scripts/gha/check-needs-results.sh +19 -0
- package/scripts/gha/download-with-diagnostics.sh +52 -0
- package/scripts/gha/host-pressure-diagnostics.sh +179 -0
- package/scripts/gha/write-github-multiline-output.sh +60 -0
- package/scripts/runner-port-policy.json +7 -0
package/README.md
CHANGED
|
@@ -17,6 +17,13 @@ vouchington runner-port-policy --file ./policy.json
|
|
|
17
17
|
vouchington runner-port-policy --reserved 2200
|
|
18
18
|
vouchington with-host-lock --name expensive-build --timeout-seconds 60 -- make build
|
|
19
19
|
vouchington gha-runtime-audit --pr-workflow CI --push-workflow '/^Main CI \\(.+\\)$/'
|
|
20
|
+
vouchington gha-output name
|
|
21
|
+
vouchington gha-needs-results
|
|
22
|
+
vouchington download-with-diagnostics <url> <destination>
|
|
23
|
+
vouchington host-pressure-diagnostics
|
|
24
|
+
vouchington allocate-browser-safe-ports 2 --policy ./policy.json --forbidden-ports ./ports.json
|
|
25
|
+
vouchington vitest-blob-manifest <suite> [reports-directory]
|
|
26
|
+
vouchington pnpm-install --runner-lifecycle persistent --install-scripts true
|
|
20
27
|
```
|
|
21
28
|
|
|
22
29
|
Host-lock environment:
|
|
@@ -39,4 +46,7 @@ import {
|
|
|
39
46
|
import { initSqlAst, extractCreateTableMetadata } from 'vouchington-tooling/sql-ast'
|
|
40
47
|
import { splitSqlStatements, stripSqlComments } from 'vouchington-tooling/sql-scanner'
|
|
41
48
|
import { auditCiJobRuntime } from 'vouchington-tooling/gha-runtime-audit'
|
|
49
|
+
import { writeVitestBlobManifest } from 'vouchington-tooling/vitest-blob-manifest'
|
|
50
|
+
import { runInstallLifecycle } from 'vouchington-tooling/pnpm-install'
|
|
51
|
+
import { buildSharedContext, runNamedChecks } from 'vouchington-tooling/shared-context'
|
|
42
52
|
```
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
export declare const synthesizedRunnerSlot: number;
|
|
2
|
+
export declare const synthesizedPortRangeStart: number;
|
|
3
|
+
export declare const synthesizedPortRangeEnd: number;
|
|
4
|
+
export declare function requiredPort(port: number | undefined): number;
|
|
5
|
+
export declare const synthesizedSlotHostLockTestTimeoutMs: number;
|
|
6
|
+
export declare function withSynthesizedSlotHostLock<T>(body: () => Promise<T>): Promise<T>;
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import { spawn } from 'node:child_process';
|
|
2
|
+
import { existsSync } from 'node:fs';
|
|
3
|
+
import { mkdtemp, rm, writeFile } from 'node:fs/promises';
|
|
4
|
+
import { tmpdir } from 'node:os';
|
|
5
|
+
import { join, resolve } from 'node:path';
|
|
6
|
+
import { runnerPortPolicy } from '../../runner-port-policy/index.mjs';
|
|
7
|
+
const hostLockScriptPath = resolve('packages/vouchington-tooling/scripts/host-lock/with-host-lock.sh');
|
|
8
|
+
// Slot `maximumRunnerSlot` is the fleet's established synthetic ceiling for boundary tests (see
|
|
9
|
+
// allocate-browser-safe-ports.test.mts's "uses the slot-50 boundary" case) — no host runs that
|
|
10
|
+
// many runner slots, so this range never collides with a real runner's live port allocation.
|
|
11
|
+
export const synthesizedRunnerSlot = runnerPortPolicy.maximumRunnerSlot;
|
|
12
|
+
export const synthesizedPortRangeStart = runnerPortPolicy.reservedPortStart +
|
|
13
|
+
(synthesizedRunnerSlot - runnerPortPolicy.minimumRunnerSlot) * runnerPortPolicy.portsPerRunner;
|
|
14
|
+
export const synthesizedPortRangeEnd = synthesizedPortRangeStart + runnerPortPolicy.portsPerRunner - 1;
|
|
15
|
+
export function requiredPort(port) {
|
|
16
|
+
if (port === undefined)
|
|
17
|
+
throw new Error('expected an allocated port');
|
|
18
|
+
return port;
|
|
19
|
+
}
|
|
20
|
+
const hostLockName = 'allocate-browser-safe-ports-synthesized-slot';
|
|
21
|
+
const hostLockTimeoutSeconds = 60;
|
|
22
|
+
const hostLockCommandTimeoutSeconds = 60;
|
|
23
|
+
// Grace this wrapper waits past with-host-lock.sh's own --timeout-seconds before giving up on the
|
|
24
|
+
// acquired marker (mirrors the script's internal polling/teardown overhead around acquisition).
|
|
25
|
+
const hostLockAcquireGraceSeconds = 15;
|
|
26
|
+
// Fixed overhead for this wrapper's own teardown once body() returns: writing the release marker,
|
|
27
|
+
// waiting for the placeholder to exit, and removing the marker dir.
|
|
28
|
+
const hostLockWrapperTeardownMarginSeconds = 15;
|
|
29
|
+
// Single source of truth for how long a vitest `it()` calling withSynthesizedSlotHostLock must be
|
|
30
|
+
// allowed to run: the full lock-acquisition window, then a critical section that may legitimately
|
|
31
|
+
// run right up to with-host-lock's own --command-timeout-seconds watchdog, plus fixed wrapper
|
|
32
|
+
// teardown. Deriving this -- instead of a second, independent literal on the `it()` call -- is what
|
|
33
|
+
// guarantees with-host-lock's watchdog can never force-release the lock while the calling test
|
|
34
|
+
// still believes it's within its own timeout budget: with-host-lock.sh's
|
|
35
|
+
// start_command_timeout_watchdog force-kills the placeholder and unconditionally releases the lock
|
|
36
|
+
// once command_timeout_seconds elapses, regardless of whether body() has finished, so the vitest
|
|
37
|
+
// timeout must always exceed that watchdog's own worst case, never fall short of it.
|
|
38
|
+
export const synthesizedSlotHostLockTestTimeoutMs = (hostLockTimeoutSeconds +
|
|
39
|
+
hostLockAcquireGraceSeconds +
|
|
40
|
+
hostLockCommandTimeoutSeconds +
|
|
41
|
+
hostLockWrapperTeardownMarginSeconds) *
|
|
42
|
+
1000;
|
|
43
|
+
async function waitForMarker(path, timeoutMs) {
|
|
44
|
+
const deadline = Date.now() + timeoutMs;
|
|
45
|
+
while (!existsSync(path)) {
|
|
46
|
+
if (Date.now() >= deadline)
|
|
47
|
+
throw new Error(`timed out waiting for marker: ${path}`);
|
|
48
|
+
await new Promise((resolveWait) => setTimeout(resolveWait, 50));
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
// ci/with-host-lock.sh only wraps a subprocess command, but the racy resource here (binding real
|
|
52
|
+
// host ports) is in-process Node.js code. The wrapped command is therefore a placeholder that
|
|
53
|
+
// writes an "acquired" marker the instant it starts running — which is exactly when the lock is
|
|
54
|
+
// held, per with-host-lock.sh's start-release handshake — and blocks on a "release" marker until
|
|
55
|
+
// told to stop. That gates the Node-side critical section on lock acquisition/release instead of
|
|
56
|
+
// trying to run that section as the locked subprocess itself, so two concurrent copies of a test
|
|
57
|
+
// using the synthesized slot take turns instead of colliding on the same synthetic port range.
|
|
58
|
+
export async function withSynthesizedSlotHostLock(body) {
|
|
59
|
+
const markerDir = await mkdtemp(join(tmpdir(), 'host-lock-marker-'));
|
|
60
|
+
const acquiredMarker = join(markerDir, 'acquired');
|
|
61
|
+
const releaseMarker = join(markerDir, 'release');
|
|
62
|
+
const stderrChunks = [];
|
|
63
|
+
const holder = spawn('bash', [
|
|
64
|
+
hostLockScriptPath,
|
|
65
|
+
'--name',
|
|
66
|
+
hostLockName,
|
|
67
|
+
'--timeout-seconds',
|
|
68
|
+
String(hostLockTimeoutSeconds),
|
|
69
|
+
'--command-timeout-seconds',
|
|
70
|
+
String(hostLockCommandTimeoutSeconds),
|
|
71
|
+
'--',
|
|
72
|
+
'bash',
|
|
73
|
+
'-c',
|
|
74
|
+
'touch "$1"; while [ ! -e "$2" ]; do sleep 0.05; done',
|
|
75
|
+
'_',
|
|
76
|
+
acquiredMarker,
|
|
77
|
+
releaseMarker,
|
|
78
|
+
], { stdio: ['ignore', 'ignore', 'pipe'] });
|
|
79
|
+
holder.stderr?.on('data', (chunk) => stderrChunks.push(chunk));
|
|
80
|
+
const exited = new Promise((resolveExit) => {
|
|
81
|
+
holder.once('close', (code) => resolveExit(code));
|
|
82
|
+
});
|
|
83
|
+
const acquireTimeoutMs = (hostLockTimeoutSeconds + hostLockAcquireGraceSeconds) * 1000;
|
|
84
|
+
const acquired = await Promise.race([
|
|
85
|
+
waitForMarker(acquiredMarker, acquireTimeoutMs).then(() => true),
|
|
86
|
+
exited.then(() => false),
|
|
87
|
+
]);
|
|
88
|
+
if (!acquired) {
|
|
89
|
+
const code = await exited;
|
|
90
|
+
throw new Error(`with-host-lock exited before acquiring "${hostLockName}" (code ${code}): ${Buffer.concat(stderrChunks).toString('utf8')}`);
|
|
91
|
+
}
|
|
92
|
+
try {
|
|
93
|
+
return await body();
|
|
94
|
+
}
|
|
95
|
+
finally {
|
|
96
|
+
await releasePlaceholderAndAwaitExit(releaseMarker, exited);
|
|
97
|
+
await rm(markerDir, { force: true, recursive: true });
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
async function releasePlaceholderAndAwaitExit(releaseMarker, exited) {
|
|
101
|
+
await writeFile(releaseMarker, '');
|
|
102
|
+
await exited;
|
|
103
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare function parseGithubOutput(text: string): Record<string, string>;
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
export function parseGithubOutput(text) {
|
|
2
|
+
const outputs = {};
|
|
3
|
+
const lines = text.split(/\r?\n/);
|
|
4
|
+
for (let index = 0; index < lines.length; index += 1) {
|
|
5
|
+
const line = lines[index];
|
|
6
|
+
if (!line)
|
|
7
|
+
continue;
|
|
8
|
+
const heredocSeparator = line.indexOf('<<');
|
|
9
|
+
if (heredocSeparator !== -1) {
|
|
10
|
+
const name = line.slice(0, heredocSeparator);
|
|
11
|
+
const delimiter = line.slice(heredocSeparator + 2);
|
|
12
|
+
const valueLines = [];
|
|
13
|
+
index += 1;
|
|
14
|
+
while (index < lines.length) {
|
|
15
|
+
const next = lines[index];
|
|
16
|
+
if (next === undefined || next === delimiter)
|
|
17
|
+
break;
|
|
18
|
+
valueLines.push(next);
|
|
19
|
+
index += 1;
|
|
20
|
+
}
|
|
21
|
+
if (index === lines.length)
|
|
22
|
+
throw new Error(`Missing output delimiter: ${delimiter}`);
|
|
23
|
+
outputs[name] = valueLines.join('\n');
|
|
24
|
+
continue;
|
|
25
|
+
}
|
|
26
|
+
const assignmentSeparator = line.indexOf('=');
|
|
27
|
+
if (assignmentSeparator === -1)
|
|
28
|
+
throw new Error(`Invalid output record: ${line}`);
|
|
29
|
+
outputs[line.slice(0, assignmentSeparator)] = line.slice(assignmentSeparator + 1);
|
|
30
|
+
}
|
|
31
|
+
return outputs;
|
|
32
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare function runPnpmInstallCli(args: readonly string[]): Promise<number>;
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { appendFile } from 'node:fs/promises';
|
|
2
|
+
import { parseInstallOptions, runInstallLifecycle } from '../../pnpm-install/index.mjs';
|
|
3
|
+
export async function runPnpmInstallCli(args) {
|
|
4
|
+
const started = performance.now();
|
|
5
|
+
let mode = 'unknown';
|
|
6
|
+
let outcome = 'failed';
|
|
7
|
+
try {
|
|
8
|
+
mode = await runInstallLifecycle(parseInstallOptions([...args]));
|
|
9
|
+
outcome = 'completed';
|
|
10
|
+
return 0;
|
|
11
|
+
}
|
|
12
|
+
catch (error) {
|
|
13
|
+
process.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`);
|
|
14
|
+
return 1;
|
|
15
|
+
}
|
|
16
|
+
finally {
|
|
17
|
+
const summary = process.env.GITHUB_STEP_SUMMARY;
|
|
18
|
+
if (summary) {
|
|
19
|
+
try {
|
|
20
|
+
await appendFile(summary, `pnpm install: ${mode} ${outcome} in ${Math.round(performance.now() - started)}ms\n`);
|
|
21
|
+
}
|
|
22
|
+
catch (error) {
|
|
23
|
+
console.warn(`unable to append pnpm install summary: ${error instanceof Error ? error.message : String(error)}`);
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
}
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
import { type SpawnSyncReturns } from 'node:child_process';
|
|
2
|
+
export type ScriptSpawn = (command: string, args: readonly string[], options: {
|
|
3
|
+
stdio: 'inherit';
|
|
4
|
+
}) => Pick<SpawnSyncReturns<Buffer>, 'error' | 'status'>;
|
|
5
|
+
export declare function runScript(command: string, scriptPath: string, args: readonly string[], spawn?: ScriptSpawn): number;
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import { spawnSync } from 'node:child_process';
|
|
2
|
+
export function runScript(command, scriptPath, args, spawn = spawnSync) {
|
|
3
|
+
const result = spawn(command, [scriptPath, ...args], { stdio: 'inherit' });
|
|
4
|
+
if (result.error)
|
|
5
|
+
throw result.error;
|
|
6
|
+
return result.status ?? 1;
|
|
7
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare function runVitestBlobManifestCommand(args: readonly string[]): number;
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { runVitestBlobManifestCli } from '../../vitest-blob-manifest/cli.mjs';
|
|
2
|
+
export function runVitestBlobManifestCommand(args) {
|
|
3
|
+
try {
|
|
4
|
+
runVitestBlobManifestCli(args);
|
|
5
|
+
return 0;
|
|
6
|
+
}
|
|
7
|
+
catch (error) {
|
|
8
|
+
process.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`);
|
|
9
|
+
return 1;
|
|
10
|
+
}
|
|
11
|
+
}
|
package/dist/cli/index.mjs
CHANGED
|
@@ -4,10 +4,30 @@ import { resolve } from 'node:path';
|
|
|
4
4
|
import { pathToFileURL } from 'node:url';
|
|
5
5
|
import { readPackageVersion } from '../package-version.mjs';
|
|
6
6
|
import { runGhaRuntimeAudit } from './commands/gha-runtime-audit.mjs';
|
|
7
|
+
import { runPnpmInstallCli } from './commands/pnpm-install.mjs';
|
|
7
8
|
import { runRunnerPortPolicy } from './commands/runner-port-policy.mjs';
|
|
9
|
+
import { runScript } from './commands/spawn-script.mjs';
|
|
10
|
+
import { runVitestBlobManifestCommand } from './commands/vitest-blob-manifest.mjs';
|
|
8
11
|
import { runWithHostLock } from './commands/with-host-lock.mjs';
|
|
9
12
|
import { parseCli } from './parse.mjs';
|
|
13
|
+
import { packageScriptPath } from './script-path.mjs';
|
|
10
14
|
import { printUsage } from './usage.mjs';
|
|
15
|
+
const SCRIPT_PATHS = {
|
|
16
|
+
'gha-output': { command: 'bash', path: 'scripts/gha/write-github-multiline-output.sh' },
|
|
17
|
+
'gha-needs-results': { command: 'bash', path: 'scripts/gha/check-needs-results.sh' },
|
|
18
|
+
'download-with-diagnostics': {
|
|
19
|
+
command: 'bash',
|
|
20
|
+
path: 'scripts/gha/download-with-diagnostics.sh',
|
|
21
|
+
},
|
|
22
|
+
'host-pressure-diagnostics': {
|
|
23
|
+
command: 'bash',
|
|
24
|
+
path: 'scripts/gha/host-pressure-diagnostics.sh',
|
|
25
|
+
},
|
|
26
|
+
'allocate-browser-safe-ports': {
|
|
27
|
+
command: 'python3',
|
|
28
|
+
path: 'scripts/allocate-browser-safe-ports.py',
|
|
29
|
+
},
|
|
30
|
+
};
|
|
11
31
|
export function runCli(argv = process.argv) {
|
|
12
32
|
const parsed = parseCli(argv);
|
|
13
33
|
switch (parsed.kind) {
|
|
@@ -27,6 +47,14 @@ export function runCli(argv = process.argv) {
|
|
|
27
47
|
return runWithHostLock(parsed.args);
|
|
28
48
|
case 'gha-runtime-audit':
|
|
29
49
|
return runGhaRuntimeAudit(parsed);
|
|
50
|
+
case 'script': {
|
|
51
|
+
const spec = SCRIPT_PATHS[parsed.command];
|
|
52
|
+
return runScript(spec.command, packageScriptPath(spec.path), parsed.args);
|
|
53
|
+
}
|
|
54
|
+
case 'pnpm-install':
|
|
55
|
+
return runPnpmInstallCli(parsed.args);
|
|
56
|
+
case 'vitest-blob-manifest':
|
|
57
|
+
return runVitestBlobManifestCommand(parsed.args);
|
|
30
58
|
}
|
|
31
59
|
}
|
|
32
60
|
function readInstalledVersion() {
|
package/dist/cli/parse.d.mts
CHANGED
|
@@ -13,5 +13,16 @@ export type ParsedCli = {
|
|
|
13
13
|
} | {
|
|
14
14
|
kind: 'with-host-lock';
|
|
15
15
|
args: string[];
|
|
16
|
+
} | {
|
|
17
|
+
kind: 'script';
|
|
18
|
+
command: ScriptCommand;
|
|
19
|
+
args: string[];
|
|
20
|
+
} | {
|
|
21
|
+
kind: 'pnpm-install';
|
|
22
|
+
args: string[];
|
|
23
|
+
} | {
|
|
24
|
+
kind: 'vitest-blob-manifest';
|
|
25
|
+
args: string[];
|
|
16
26
|
} | ParsedGhaRuntimeAudit;
|
|
27
|
+
export type ScriptCommand = 'gha-output' | 'gha-needs-results' | 'download-with-diagnostics' | 'host-pressure-diagnostics' | 'allocate-browser-safe-ports';
|
|
17
28
|
export declare function parseCli(argv: readonly string[]): ParsedCli;
|
package/dist/cli/parse.mjs
CHANGED
|
@@ -1,4 +1,11 @@
|
|
|
1
1
|
import { parseGhaRuntimeAudit } from './parse-gha-runtime-audit.mjs';
|
|
2
|
+
const SCRIPT_COMMANDS = new Set([
|
|
3
|
+
'gha-output',
|
|
4
|
+
'gha-needs-results',
|
|
5
|
+
'download-with-diagnostics',
|
|
6
|
+
'host-pressure-diagnostics',
|
|
7
|
+
'allocate-browser-safe-ports',
|
|
8
|
+
]);
|
|
2
9
|
export function parseCli(argv) {
|
|
3
10
|
const args = argv.slice(2);
|
|
4
11
|
if (args.length === 0 || args[0] === '--help' || args[0] === '-h')
|
|
@@ -12,6 +19,13 @@ export function parseCli(argv) {
|
|
|
12
19
|
return { kind: 'with-host-lock', args: rest };
|
|
13
20
|
if (command === 'gha-runtime-audit')
|
|
14
21
|
return parseGhaRuntimeAudit(rest);
|
|
22
|
+
if (command === 'pnpm-install')
|
|
23
|
+
return { kind: 'pnpm-install', args: rest };
|
|
24
|
+
if (command === 'vitest-blob-manifest')
|
|
25
|
+
return { kind: 'vitest-blob-manifest', args: rest };
|
|
26
|
+
if (command !== undefined && SCRIPT_COMMANDS.has(command)) {
|
|
27
|
+
return { kind: 'script', command: command, args: rest };
|
|
28
|
+
}
|
|
15
29
|
return { kind: 'error', message: `unknown command: ${command}` };
|
|
16
30
|
}
|
|
17
31
|
function parseRunnerPortPolicy(args) {
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare function packageScriptPath(relativeFromPackageRoot: string): string;
|
package/dist/cli/usage.d.mts
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
|
-
export declare const USAGE = "Usage: vouchington <command> [options]\n\nCommands:\n runner-port-policy Print or validate a runner port policy\n with-host-lock Run a command under a host-wide lock\n gha-runtime-audit
|
|
1
|
+
export declare const USAGE = "Usage: vouchington <command> [options]\n\nCommands:\n runner-port-policy Print or validate a runner port policy\n with-host-lock Run a command under a host-wide lock\n gha-runtime-audit Audit successful GitHub Actions job runtimes\n gha-output Write a collision-safe multiline GITHUB_OUTPUT record\n gha-needs-results Fail if required GitHub Actions job results failed\n download-with-diagnostics Download a URL and report HTTP status on failure\n host-pressure-diagnostics Print a bounded host memory/OOM/PSI snapshot\n allocate-browser-safe-ports Allocate Fetch-safe localhost ports\n vitest-blob-manifest Stamp a vitest-blob-manifest:v1 identity file\n pnpm-install Install a pnpm workspace with retry and release-age fail-fast\n\nOptions:\n -h, --help Show this help\n -v, --version Print the package version\n\nrunner-port-policy\n (no args) Print the shipped policy as JSON\n --file <path> Validate and print a policy file\n --reserved <port> Print true if the port is reserved\n\nwith-host-lock\n --name <family>\n [--slots <n>]\n --timeout-seconds <n>\n [--command-timeout-seconds <n>]\n [--failure-diagnostics <absolute-script>]\n [--on-acquire-timeout fail|run-unlocked]\n -- <command> [args...]\n\ngha-runtime-audit\n [--repository owner/name] Default GITHUB_REPOSITORY\n [--branch main]\n --pr-workflow <name|/regex/> Repeatable\n --push-workflow <name|/regex/> Repeatable\n\ngha-output <name>\ngha-needs-results [label]\ndownload-with-diagnostics <url> <destination> [-- curl-args...]\nhost-pressure-diagnostics\nallocate-browser-safe-ports [count] [--policy path] [--forbidden-ports path]\nvitest-blob-manifest <suite> [reports-directory]\npnpm-install --runner-lifecycle persistent|ephemeral|ephemeral-full --install-scripts true|false\n";
|
|
2
2
|
export declare function printUsage(stream?: NodeJS.WritableStream): void;
|
package/dist/cli/usage.mjs
CHANGED
|
@@ -3,7 +3,14 @@ export const USAGE = `Usage: vouchington <command> [options]
|
|
|
3
3
|
Commands:
|
|
4
4
|
runner-port-policy Print or validate a runner port policy
|
|
5
5
|
with-host-lock Run a command under a host-wide lock
|
|
6
|
-
gha-runtime-audit
|
|
6
|
+
gha-runtime-audit Audit successful GitHub Actions job runtimes
|
|
7
|
+
gha-output Write a collision-safe multiline GITHUB_OUTPUT record
|
|
8
|
+
gha-needs-results Fail if required GitHub Actions job results failed
|
|
9
|
+
download-with-diagnostics Download a URL and report HTTP status on failure
|
|
10
|
+
host-pressure-diagnostics Print a bounded host memory/OOM/PSI snapshot
|
|
11
|
+
allocate-browser-safe-ports Allocate Fetch-safe localhost ports
|
|
12
|
+
vitest-blob-manifest Stamp a vitest-blob-manifest:v1 identity file
|
|
13
|
+
pnpm-install Install a pnpm workspace with retry and release-age fail-fast
|
|
7
14
|
|
|
8
15
|
Options:
|
|
9
16
|
-h, --help Show this help
|
|
@@ -28,6 +35,14 @@ gha-runtime-audit
|
|
|
28
35
|
[--branch main]
|
|
29
36
|
--pr-workflow <name|/regex/> Repeatable
|
|
30
37
|
--push-workflow <name|/regex/> Repeatable
|
|
38
|
+
|
|
39
|
+
gha-output <name>
|
|
40
|
+
gha-needs-results [label]
|
|
41
|
+
download-with-diagnostics <url> <destination> [-- curl-args...]
|
|
42
|
+
host-pressure-diagnostics
|
|
43
|
+
allocate-browser-safe-ports [count] [--policy path] [--forbidden-ports path]
|
|
44
|
+
vitest-blob-manifest <suite> [reports-directory]
|
|
45
|
+
pnpm-install --runner-lifecycle persistent|ephemeral|ephemeral-full --install-scripts true|false
|
|
31
46
|
`;
|
|
32
47
|
export function printUsage(stream = process.stdout) {
|
|
33
48
|
stream.write(USAGE);
|
package/dist/index.d.mts
CHANGED
|
@@ -5,3 +5,9 @@ export type { ForeignKey, SqlCreateIndexMetadata, SqlCreateTableColumn, SqlCreat
|
|
|
5
5
|
export { dollarQuoteEnd, lineOf, maskSqlQuotedText, readDollarQuoteDelimiter, readStringLiteral, splitSqlStatements, sqlFragments, stripSqlComments, } from './sql-scanner/index.mts';
|
|
6
6
|
export { auditCiJobRuntime, parseWorkflowNameMatch } from './gha-runtime-audit/index.mts';
|
|
7
7
|
export type { GhApiExecutor, RuntimeAuditOptions, RuntimeAuditResult, RuntimeAuditWorkflowFilter, RuntimeJobResult, RuntimeSample, } from './gha-runtime-audit/index.mts';
|
|
8
|
+
export { createVitestBlobManifest, inspectVitestBlobBundle, parseVitestBlobManifest, serializeVitestBlobManifest, VITEST_BLOB_MANIFEST_FILENAME, VITEST_BLOB_MANIFEST_VERSION, vitestBlobBundlePaths, writeVitestBlobManifest, } from './vitest-blob-manifest/index.mts';
|
|
9
|
+
export type { InspectedVitestBlobBundle, VitestBlobIdentity, VitestBlobManifest, } from './vitest-blob-manifest/index.mts';
|
|
10
|
+
export { findWorkspaceLinkMismatches, formatReleaseAgeFailure, INSTALL_TERMINATION_FAILED, isReleaseAgeViolation, parseInstallOptions, parseReleaseAgeViolations, runInstallLifecycle, } from './pnpm-install/index.mts';
|
|
11
|
+
export type { InstallOptions, Lifecycle } from './pnpm-install/index.mts';
|
|
12
|
+
export { buildContextFromTrackedFiles, buildSharedContext, gitEnv, runNamedChecks, } from './shared-context/index.mts';
|
|
13
|
+
export type { NamedCheck, SharedContext } from './shared-context/index.mts';
|
package/dist/index.mjs
CHANGED
|
@@ -2,3 +2,6 @@ export { EphemeralListenerAttemptsExhaustedError, isRunnerReservedPort, listenOn
|
|
|
2
2
|
export { extractAlterTableAddColumnLocations, extractCreateIndexMetadata, extractCreateTableMetadata, extractDefaultFunction, extractDropIndexMetadata, extractFuncCallArgColumnNames, extractMigrationConstraintMetadata, initSqlAst, lineOfUtf8ByteOffset, MissingSqlAstParserError, parseSql, } from './sql-ast/index.mjs';
|
|
3
3
|
export { dollarQuoteEnd, lineOf, maskSqlQuotedText, readDollarQuoteDelimiter, readStringLiteral, splitSqlStatements, sqlFragments, stripSqlComments, } from './sql-scanner/index.mjs';
|
|
4
4
|
export { auditCiJobRuntime, parseWorkflowNameMatch } from './gha-runtime-audit/index.mjs';
|
|
5
|
+
export { createVitestBlobManifest, inspectVitestBlobBundle, parseVitestBlobManifest, serializeVitestBlobManifest, VITEST_BLOB_MANIFEST_FILENAME, VITEST_BLOB_MANIFEST_VERSION, vitestBlobBundlePaths, writeVitestBlobManifest, } from './vitest-blob-manifest/index.mjs';
|
|
6
|
+
export { findWorkspaceLinkMismatches, formatReleaseAgeFailure, INSTALL_TERMINATION_FAILED, isReleaseAgeViolation, parseInstallOptions, parseReleaseAgeViolations, runInstallLifecycle, } from './pnpm-install/index.mjs';
|
|
7
|
+
export { buildContextFromTrackedFiles, buildSharedContext, gitEnv, runNamedChecks, } from './shared-context/index.mjs';
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import type { CommandResult, InstallOptions } from './support.mts';
|
|
2
|
+
/**
|
|
3
|
+
* Spawn one `pnpm` attempt. Both stdout and stderr are always captured (so a terminal failure
|
|
4
|
+
* like a release-age violation can be classified from `errorOutput`) and, unless `capture` is
|
|
5
|
+
* set, forwarded live to the parent process's streams — `capture` is for structured calls (e.g.
|
|
6
|
+
* `pnpm m ls --json`) whose stdout the caller parses rather than displays.
|
|
7
|
+
*/
|
|
8
|
+
export declare function runPnpm(args: string[], options: InstallOptions, capture?: boolean): Promise<CommandResult>;
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import { spawn } from 'node:child_process';
|
|
2
|
+
// oxfmt-ignore
|
|
3
|
+
import { INSTALL_TERMINATION_FAILED, installExitCode, startInstallHeartbeat, terminateSafeProcessGroup } from './process.mjs';
|
|
4
|
+
/**
|
|
5
|
+
* Spawn one `pnpm` attempt. Both stdout and stderr are always captured (so a terminal failure
|
|
6
|
+
* like a release-age violation can be classified from `errorOutput`) and, unless `capture` is
|
|
7
|
+
* set, forwarded live to the parent process's streams — `capture` is for structured calls (e.g.
|
|
8
|
+
* `pnpm m ls --json`) whose stdout the caller parses rather than displays.
|
|
9
|
+
*/
|
|
10
|
+
export function runPnpm(args, options, capture = false) {
|
|
11
|
+
return new Promise((resolve) => {
|
|
12
|
+
const errorOutput = [];
|
|
13
|
+
const output = [];
|
|
14
|
+
// oxfmt-ignore
|
|
15
|
+
const result = (code) => ({ code, errorOutput: Buffer.concat(errorOutput).toString(), output: Buffer.concat(output).toString() });
|
|
16
|
+
let completed = false;
|
|
17
|
+
const complete = (result) => {
|
|
18
|
+
if (completed)
|
|
19
|
+
return;
|
|
20
|
+
completed = true;
|
|
21
|
+
resolve(result);
|
|
22
|
+
};
|
|
23
|
+
let child;
|
|
24
|
+
try {
|
|
25
|
+
child = spawn('pnpm', args, {
|
|
26
|
+
detached: true,
|
|
27
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
28
|
+
});
|
|
29
|
+
}
|
|
30
|
+
catch (error) {
|
|
31
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
32
|
+
console.error(`pnpm failed to spawn: ${message}`);
|
|
33
|
+
complete({ code: 1, errorOutput: message, output: '' });
|
|
34
|
+
return;
|
|
35
|
+
}
|
|
36
|
+
child.stdout?.on('data', (data) => {
|
|
37
|
+
output.push(data);
|
|
38
|
+
if (!capture)
|
|
39
|
+
process.stdout.write(data);
|
|
40
|
+
});
|
|
41
|
+
child.stderr?.on('data', (data) => {
|
|
42
|
+
errorOutput.push(data);
|
|
43
|
+
if (!capture)
|
|
44
|
+
process.stderr.write(data);
|
|
45
|
+
});
|
|
46
|
+
child.once('error', (error) => {
|
|
47
|
+
console.error(`pnpm failed to start: ${error.message}`);
|
|
48
|
+
complete({ code: 1, errorOutput: error.message, output: '' });
|
|
49
|
+
});
|
|
50
|
+
const heartbeat = startInstallHeartbeat();
|
|
51
|
+
let termination;
|
|
52
|
+
// Benign race: if the timeout callback and the 'close' handler both fire, `complete`'s dedup
|
|
53
|
+
// guard lets only the first resolve. `timedOut` is only read by the winner, so whichever wins
|
|
54
|
+
// sees the correct value for its own case (true when the timeout won, false when close won).
|
|
55
|
+
let timedOut = false;
|
|
56
|
+
const terminate = () => (termination ??= terminateSafeProcessGroup(child.pid));
|
|
57
|
+
const timeout = options.commandTimeoutSeconds === 0
|
|
58
|
+
? undefined
|
|
59
|
+
: setTimeout(() => {
|
|
60
|
+
/* v8 ignore next -- close can win the race and already settle */
|
|
61
|
+
if (completed)
|
|
62
|
+
return;
|
|
63
|
+
timedOut = true;
|
|
64
|
+
void terminate()
|
|
65
|
+
.then((stopped) => complete(result(stopped ? 1 : INSTALL_TERMINATION_FAILED)))
|
|
66
|
+
.catch((error) => {
|
|
67
|
+
console.error(`pnpm install termination failed: ${String(error)}`);
|
|
68
|
+
complete(result(INSTALL_TERMINATION_FAILED));
|
|
69
|
+
})
|
|
70
|
+
.finally(() => {
|
|
71
|
+
clearInterval(heartbeat);
|
|
72
|
+
});
|
|
73
|
+
}, options.commandTimeoutSeconds * 1000);
|
|
74
|
+
timeout?.unref();
|
|
75
|
+
child.once('close', async (code) => {
|
|
76
|
+
try {
|
|
77
|
+
await termination;
|
|
78
|
+
complete(result(installExitCode(code, timedOut)));
|
|
79
|
+
}
|
|
80
|
+
finally {
|
|
81
|
+
clearInterval(heartbeat);
|
|
82
|
+
// oxlint-disable-next-line promise/no-multiple-resolved -- oxlint reports the multi-path settlement at this finally line; the sole `resolve` call site is first-winner-guarded by `completed` inside `complete`
|
|
83
|
+
if (timeout)
|
|
84
|
+
clearTimeout(timeout);
|
|
85
|
+
}
|
|
86
|
+
});
|
|
87
|
+
});
|
|
88
|
+
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
export { runInstallLifecycle } from './runner.mts';
|
|
2
|
+
export { runPnpm } from './exec.mts';
|
|
3
|
+
export { baseInstallArgs, findWorkspaceLinkMismatches, listWorkspaces, logWorkspaceLinkMismatches, parseInstallOptions, reportGlibcVersionRuntime, } from './support.mts';
|
|
4
|
+
export type { CaptureCommand, CommandResult, InstallOptions, Lifecycle, Workspace, WorkspaceLinkMismatch, } from './support.mts';
|
|
5
|
+
export { formatReleaseAgeFailure, isReleaseAgeViolation, parseReleaseAgeViolations, } from './release-age.mts';
|
|
6
|
+
export { INSTALL_TERMINATION_FAILED, installExitCode, safeProcessGroup, startInstallHeartbeat, terminateProcessGroup, terminateSafeProcessGroup, } from './process.mts';
|
|
7
|
+
export { persistentDependencyTreeIsCold, persistentMetadataFingerprint, persistentMetadataMatches, writePersistentMetadataStamp, } from './metadata.mts';
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
export { runInstallLifecycle } from './runner.mjs';
|
|
2
|
+
export { runPnpm } from './exec.mjs';
|
|
3
|
+
export { baseInstallArgs, findWorkspaceLinkMismatches, listWorkspaces, logWorkspaceLinkMismatches, parseInstallOptions, reportGlibcVersionRuntime, } from './support.mjs';
|
|
4
|
+
export { formatReleaseAgeFailure, isReleaseAgeViolation, parseReleaseAgeViolations, } from './release-age.mjs';
|
|
5
|
+
export { INSTALL_TERMINATION_FAILED, installExitCode, safeProcessGroup, startInstallHeartbeat, terminateProcessGroup, terminateSafeProcessGroup, } from './process.mjs';
|
|
6
|
+
export { persistentDependencyTreeIsCold, persistentMetadataFingerprint, persistentMetadataMatches, writePersistentMetadataStamp, } from './metadata.mjs';
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
import { type CaptureCommand } from './support.mts';
|
|
2
|
+
export declare function persistentMetadataFingerprint(runCapture: CaptureCommand, installScripts: boolean): Promise<string>;
|
|
3
|
+
export declare function persistentMetadataMatches(fingerprint: string): Promise<boolean>;
|
|
4
|
+
export declare function persistentDependencyTreeIsCold(): Promise<boolean>;
|
|
5
|
+
export declare function writePersistentMetadataStamp(fingerprint: string): Promise<void>;
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import { mkdir, readFile, rename, rm, stat, writeFile } from 'node:fs/promises';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import { listWorkspaces, reportGlibcVersionRuntime } from './support.mjs';
|
|
5
|
+
function persistentMetadataStampPath() {
|
|
6
|
+
return path.join(process.cwd(), 'node_modules', '.pnpm-install-metadata-health.json');
|
|
7
|
+
}
|
|
8
|
+
function fail(message) {
|
|
9
|
+
throw new Error(message);
|
|
10
|
+
}
|
|
11
|
+
function addFingerprintInput(hash, label, value) {
|
|
12
|
+
hash.update(`${label.length}:${label}${value.length}:${value}`);
|
|
13
|
+
}
|
|
14
|
+
async function optionalFile(pathname) {
|
|
15
|
+
try {
|
|
16
|
+
return await readFile(pathname, 'utf8');
|
|
17
|
+
}
|
|
18
|
+
catch (error) {
|
|
19
|
+
if (error.code === 'ENOENT')
|
|
20
|
+
return '';
|
|
21
|
+
throw error;
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
function runtimePlatformIdentity() {
|
|
25
|
+
const report = process.report?.getReport();
|
|
26
|
+
return JSON.stringify({
|
|
27
|
+
arch: process.arch,
|
|
28
|
+
glibc: reportGlibcVersionRuntime(report),
|
|
29
|
+
modules: process.versions.modules,
|
|
30
|
+
node: process.version,
|
|
31
|
+
npmConfigArch: process.env.npm_config_arch ?? '',
|
|
32
|
+
npmConfigLibc: process.env.npm_config_libc ?? '',
|
|
33
|
+
npmConfigPlatform: process.env.npm_config_platform ?? '',
|
|
34
|
+
platform: process.platform,
|
|
35
|
+
});
|
|
36
|
+
}
|
|
37
|
+
export async function persistentMetadataFingerprint(runCapture, installScripts) {
|
|
38
|
+
const workspaces = (await listWorkspaces(runCapture)).toSorted((left, right) => left.path.localeCompare(right.path));
|
|
39
|
+
const pnpmVersion = await runCapture(['--version']);
|
|
40
|
+
if (pnpmVersion.code !== 0)
|
|
41
|
+
fail(`pnpm --version failed: ${pnpmVersion.errorOutput?.trim() || pnpmVersion.output.trim() || 'unknown error'}`);
|
|
42
|
+
const hash = createHash('sha256');
|
|
43
|
+
addFingerprintInput(hash, 'runtime', runtimePlatformIdentity());
|
|
44
|
+
addFingerprintInput(hash, 'pnpm', pnpmVersion.output.trim());
|
|
45
|
+
addFingerprintInput(hash, 'installScripts', String(installScripts));
|
|
46
|
+
for (const filename of [
|
|
47
|
+
'pnpm-lock.yaml',
|
|
48
|
+
'pnpm-workspace.yaml',
|
|
49
|
+
'.npmrc',
|
|
50
|
+
'.pnpmfile.cjs',
|
|
51
|
+
'.pnpmfile.mjs',
|
|
52
|
+
])
|
|
53
|
+
addFingerprintInput(hash, filename, await optionalFile(path.join(process.cwd(), filename)));
|
|
54
|
+
for (const workspace of workspaces)
|
|
55
|
+
addFingerprintInput(hash, path.relative(process.cwd(), path.join(workspace.path, 'package.json')), await readFile(path.join(workspace.path, 'package.json'), 'utf8'));
|
|
56
|
+
return hash.digest('hex');
|
|
57
|
+
}
|
|
58
|
+
export async function persistentMetadataMatches(fingerprint) {
|
|
59
|
+
try {
|
|
60
|
+
const stamp = JSON.parse(await readFile(persistentMetadataStampPath(), 'utf8'));
|
|
61
|
+
return stamp.version === 1 && stamp.fingerprint === fingerprint;
|
|
62
|
+
}
|
|
63
|
+
catch {
|
|
64
|
+
return false;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
export async function persistentDependencyTreeIsCold() {
|
|
68
|
+
try {
|
|
69
|
+
await stat(path.dirname(persistentMetadataStampPath()));
|
|
70
|
+
return false;
|
|
71
|
+
}
|
|
72
|
+
catch (error) {
|
|
73
|
+
if (error.code === 'ENOENT')
|
|
74
|
+
return true;
|
|
75
|
+
/* v8 ignore next -- non-ENOENT stat failures are host-specific */
|
|
76
|
+
throw error;
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
export async function writePersistentMetadataStamp(fingerprint) {
|
|
80
|
+
const stampPath = persistentMetadataStampPath();
|
|
81
|
+
const directory = path.dirname(stampPath);
|
|
82
|
+
const temporary = `${stampPath}.${process.pid}.tmp`;
|
|
83
|
+
await mkdir(directory, { recursive: true });
|
|
84
|
+
try {
|
|
85
|
+
await writeFile(temporary, `${JSON.stringify({ fingerprint, version: 1 })}\n`);
|
|
86
|
+
await rename(temporary, stampPath);
|
|
87
|
+
}
|
|
88
|
+
finally {
|
|
89
|
+
await rm(temporary, { force: true });
|
|
90
|
+
}
|
|
91
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
export type FixtureOptions = {
|
|
2
|
+
commandTimeoutSeconds?: number;
|
|
3
|
+
installScripts?: boolean;
|
|
4
|
+
lifecycle?: 'ephemeral' | 'ephemeral-full' | 'persistent';
|
|
5
|
+
maxAttempts?: number;
|
|
6
|
+
selectors?: string;
|
|
7
|
+
};
|
|
8
|
+
export declare function makeFixture(): Promise<{
|
|
9
|
+
consumer: string;
|
|
10
|
+
dependency: string;
|
|
11
|
+
dependencyLink: string;
|
|
12
|
+
env: NodeJS.ProcessEnv;
|
|
13
|
+
pnpmLog: string;
|
|
14
|
+
root: string;
|
|
15
|
+
summary: string;
|
|
16
|
+
}>;
|
|
17
|
+
export declare function runInstaller(fixture: Awaited<ReturnType<typeof makeFixture>>, options?: FixtureOptions): Promise<{
|
|
18
|
+
stdout: string;
|
|
19
|
+
stderr: string;
|
|
20
|
+
}>;
|
|
21
|
+
export declare function installCalls(fixture: Awaited<ReturnType<typeof makeFixture>>): Promise<string[]>;
|
|
22
|
+
export declare function resetInstallCalls(fixture: Awaited<ReturnType<typeof makeFixture>>): Promise<void>;
|