vouchington-tooling 0.0.3 → 0.0.5
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 +12 -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/gha-runtime-audit.d.mts +3 -0
- package/dist/cli/commands/gha-runtime-audit.mjs +23 -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.d.mts +1 -1
- package/dist/cli/index.mjs +43 -2
- package/dist/cli/parse-gha-runtime-audit.d.mts +13 -0
- package/dist/cli/parse-gha-runtime-audit.mjs +44 -0
- package/dist/cli/parse.d.mts +13 -1
- package/dist/cli/parse.mjs +17 -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 +22 -0
- package/dist/gha-runtime-audit/audit.d.mts +3 -0
- package/dist/gha-runtime-audit/audit.mjs +106 -0
- package/dist/gha-runtime-audit/index.d.mts +5 -0
- package/dist/gha-runtime-audit/index.mjs +3 -0
- package/dist/gha-runtime-audit/index.test-helpers.d.mts +14 -0
- package/dist/gha-runtime-audit/index.test-helpers.mjs +63 -0
- package/dist/gha-runtime-audit/model.d.mts +60 -0
- package/dist/gha-runtime-audit/model.mjs +83 -0
- package/dist/gha-runtime-audit/results.d.mts +15 -0
- package/dist/gha-runtime-audit/results.mjs +48 -0
- package/dist/gha-runtime-audit/scope.d.mts +29 -0
- package/dist/gha-runtime-audit/scope.mjs +39 -0
- package/dist/index.d.mts +8 -0
- package/dist/index.mjs +4 -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 +5 -0
- package/dist/pnpm-install/index.mjs +4 -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 +21 -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
|
@@ -16,6 +16,14 @@ vouchington runner-port-policy
|
|
|
16
16
|
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
|
+
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
|
|
19
27
|
```
|
|
20
28
|
|
|
21
29
|
Host-lock environment:
|
|
@@ -37,4 +45,8 @@ import {
|
|
|
37
45
|
} from 'vouchington-tooling/runner-port-policy'
|
|
38
46
|
import { initSqlAst, extractCreateTableMetadata } from 'vouchington-tooling/sql-ast'
|
|
39
47
|
import { splitSqlStatements, stripSqlComments } from 'vouchington-tooling/sql-scanner'
|
|
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'
|
|
40
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,3 @@
|
|
|
1
|
+
import { type GhApiExecutor } from '../../gha-runtime-audit/index.mts';
|
|
2
|
+
import type { ParsedGhaRuntimeAudit } from '../parse-gha-runtime-audit.mts';
|
|
3
|
+
export declare function runGhaRuntimeAudit(parsed: ParsedGhaRuntimeAudit, execute?: GhApiExecutor, env?: NodeJS.ProcessEnv): Promise<number>;
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { execFileSync } from 'node:child_process';
|
|
2
|
+
import { auditCiJobRuntime } from '../../gha-runtime-audit/index.mjs';
|
|
3
|
+
/* v8 ignore next 10 -- live `gh api` adapter */
|
|
4
|
+
function defaultGhApiExecutor() {
|
|
5
|
+
return async (request) => JSON.parse(execFileSync('gh', ['api', request.endpoint, '--jq', request.jq], {
|
|
6
|
+
encoding: 'utf8',
|
|
7
|
+
maxBuffer: 16 * 1024 * 1024,
|
|
8
|
+
}));
|
|
9
|
+
}
|
|
10
|
+
export async function runGhaRuntimeAudit(parsed, execute = defaultGhApiExecutor(), env = process.env) {
|
|
11
|
+
const repository = parsed.repository ?? env['GITHUB_REPOSITORY'];
|
|
12
|
+
if (!repository) {
|
|
13
|
+
process.stderr.write('vouchington: --repository or GITHUB_REPOSITORY is required\n');
|
|
14
|
+
return 2;
|
|
15
|
+
}
|
|
16
|
+
const result = await auditCiJobRuntime(execute, {
|
|
17
|
+
repository,
|
|
18
|
+
workflows: parsed.workflows,
|
|
19
|
+
...(parsed.branch === undefined ? {} : { branch: parsed.branch }),
|
|
20
|
+
});
|
|
21
|
+
process.stdout.write(`${JSON.stringify(result, null, 2)}\n`);
|
|
22
|
+
return 0;
|
|
23
|
+
}
|
|
@@ -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.d.mts
CHANGED
package/dist/cli/index.mjs
CHANGED
|
@@ -3,10 +3,31 @@ import { readFileSync, realpathSync } from 'node:fs';
|
|
|
3
3
|
import { resolve } from 'node:path';
|
|
4
4
|
import { pathToFileURL } from 'node:url';
|
|
5
5
|
import { readPackageVersion } from '../package-version.mjs';
|
|
6
|
+
import { runGhaRuntimeAudit } from './commands/gha-runtime-audit.mjs';
|
|
7
|
+
import { runPnpmInstallCli } from './commands/pnpm-install.mjs';
|
|
6
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';
|
|
7
11
|
import { runWithHostLock } from './commands/with-host-lock.mjs';
|
|
8
12
|
import { parseCli } from './parse.mjs';
|
|
13
|
+
import { packageScriptPath } from './script-path.mjs';
|
|
9
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
|
+
};
|
|
10
31
|
export function runCli(argv = process.argv) {
|
|
11
32
|
const parsed = parseCli(argv);
|
|
12
33
|
switch (parsed.kind) {
|
|
@@ -24,6 +45,16 @@ export function runCli(argv = process.argv) {
|
|
|
24
45
|
return runRunnerPortPolicy(parsed);
|
|
25
46
|
case 'with-host-lock':
|
|
26
47
|
return runWithHostLock(parsed.args);
|
|
48
|
+
case 'gha-runtime-audit':
|
|
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);
|
|
27
58
|
}
|
|
28
59
|
}
|
|
29
60
|
function readInstalledVersion() {
|
|
@@ -39,7 +70,17 @@ export function isMainModule(metaUrl, argv1) {
|
|
|
39
70
|
return metaUrl === pathToFileURL(resolve(argv1)).href;
|
|
40
71
|
}
|
|
41
72
|
}
|
|
42
|
-
/* v8 ignore next
|
|
73
|
+
/* v8 ignore next 8 */
|
|
43
74
|
if (isMainModule(import.meta.url, process.argv[1])) {
|
|
44
|
-
|
|
75
|
+
const result = runCli();
|
|
76
|
+
if (typeof result === 'number')
|
|
77
|
+
process.exitCode = result;
|
|
78
|
+
else {
|
|
79
|
+
result.then((code) => {
|
|
80
|
+
process.exitCode = code;
|
|
81
|
+
}, (error) => {
|
|
82
|
+
process.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`);
|
|
83
|
+
process.exitCode = 1;
|
|
84
|
+
});
|
|
85
|
+
}
|
|
45
86
|
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { type RuntimeAuditWorkflowFilter } from '../gha-runtime-audit/scope.mts';
|
|
2
|
+
export type ParsedGhaRuntimeAudit = {
|
|
3
|
+
kind: 'gha-runtime-audit';
|
|
4
|
+
repository?: string;
|
|
5
|
+
branch?: string;
|
|
6
|
+
workflows: RuntimeAuditWorkflowFilter[];
|
|
7
|
+
};
|
|
8
|
+
export declare function parseGhaRuntimeAudit(args: readonly string[]): ParsedGhaRuntimeAudit | {
|
|
9
|
+
kind: 'help';
|
|
10
|
+
} | {
|
|
11
|
+
kind: 'error';
|
|
12
|
+
message: string;
|
|
13
|
+
};
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { parseWorkflowNameMatch, } from '../gha-runtime-audit/scope.mjs';
|
|
2
|
+
export function parseGhaRuntimeAudit(args) {
|
|
3
|
+
let repository;
|
|
4
|
+
let branch;
|
|
5
|
+
const workflows = [];
|
|
6
|
+
for (let index = 0; index < args.length; index += 1) {
|
|
7
|
+
const flag = args[index];
|
|
8
|
+
if (flag === '--help' || flag === '-h')
|
|
9
|
+
return { kind: 'help' };
|
|
10
|
+
if (flag === '--repository' ||
|
|
11
|
+
flag === '--pr-workflow' ||
|
|
12
|
+
flag === '--push-workflow' ||
|
|
13
|
+
flag === '--branch') {
|
|
14
|
+
const value = args[index + 1];
|
|
15
|
+
if (value === undefined)
|
|
16
|
+
return { kind: 'error', message: `${flag} requires a value` };
|
|
17
|
+
index += 1;
|
|
18
|
+
if (flag === '--repository')
|
|
19
|
+
repository = value;
|
|
20
|
+
else if (flag === '--branch')
|
|
21
|
+
branch = value;
|
|
22
|
+
else {
|
|
23
|
+
workflows.push({
|
|
24
|
+
name: parseWorkflowNameMatch(value),
|
|
25
|
+
event: flag === '--pr-workflow' ? 'pull_request' : 'push',
|
|
26
|
+
});
|
|
27
|
+
}
|
|
28
|
+
continue;
|
|
29
|
+
}
|
|
30
|
+
return { kind: 'error', message: `unknown gha-runtime-audit option: ${flag}` };
|
|
31
|
+
}
|
|
32
|
+
if (workflows.length === 0) {
|
|
33
|
+
return {
|
|
34
|
+
kind: 'error',
|
|
35
|
+
message: 'gha-runtime-audit requires --pr-workflow or --push-workflow',
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
return {
|
|
39
|
+
kind: 'gha-runtime-audit',
|
|
40
|
+
workflows,
|
|
41
|
+
...(repository === undefined ? {} : { repository }),
|
|
42
|
+
...(branch === undefined ? {} : { branch }),
|
|
43
|
+
};
|
|
44
|
+
}
|
package/dist/cli/parse.d.mts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { type ParsedGhaRuntimeAudit } from './parse-gha-runtime-audit.mts';
|
|
1
2
|
export type ParsedCli = {
|
|
2
3
|
kind: 'help';
|
|
3
4
|
} | {
|
|
@@ -12,5 +13,16 @@ export type ParsedCli = {
|
|
|
12
13
|
} | {
|
|
13
14
|
kind: 'with-host-lock';
|
|
14
15
|
args: string[];
|
|
15
|
-
}
|
|
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[];
|
|
26
|
+
} | ParsedGhaRuntimeAudit;
|
|
27
|
+
export type ScriptCommand = 'gha-output' | 'gha-needs-results' | 'download-with-diagnostics' | 'host-pressure-diagnostics' | 'allocate-browser-safe-ports';
|
|
16
28
|
export declare function parseCli(argv: readonly string[]): ParsedCli;
|
package/dist/cli/parse.mjs
CHANGED
|
@@ -1,3 +1,11 @@
|
|
|
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
|
+
]);
|
|
1
9
|
export function parseCli(argv) {
|
|
2
10
|
const args = argv.slice(2);
|
|
3
11
|
if (args.length === 0 || args[0] === '--help' || args[0] === '-h')
|
|
@@ -9,6 +17,15 @@ export function parseCli(argv) {
|
|
|
9
17
|
return parseRunnerPortPolicy(rest);
|
|
10
18
|
if (command === 'with-host-lock')
|
|
11
19
|
return { kind: 'with-host-lock', args: rest };
|
|
20
|
+
if (command === 'gha-runtime-audit')
|
|
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
|
+
}
|
|
12
29
|
return { kind: 'error', message: `unknown command: ${command}` };
|
|
13
30
|
}
|
|
14
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\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";
|
|
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,6 +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 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
|
|
6
14
|
|
|
7
15
|
Options:
|
|
8
16
|
-h, --help Show this help
|
|
@@ -21,6 +29,20 @@ with-host-lock
|
|
|
21
29
|
[--failure-diagnostics <absolute-script>]
|
|
22
30
|
[--on-acquire-timeout fail|run-unlocked]
|
|
23
31
|
-- <command> [args...]
|
|
32
|
+
|
|
33
|
+
gha-runtime-audit
|
|
34
|
+
[--repository owner/name] Default GITHUB_REPOSITORY
|
|
35
|
+
[--branch main]
|
|
36
|
+
--pr-workflow <name|/regex/> Repeatable
|
|
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
|
|
24
46
|
`;
|
|
25
47
|
export function printUsage(stream = process.stdout) {
|
|
26
48
|
stream.write(USAGE);
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
import { parseArray, parseObject, parseRun, parseSample, parseWorkflow, } from './model.mjs';
|
|
2
|
+
import { buildRuntimeResults } from './results.mjs';
|
|
3
|
+
import { isRunInScope, isSelectedWorkflow, matchingWorkflowFilter, resolveRuntimeAuditOptions, } from './scope.mjs';
|
|
4
|
+
async function discoverWorkflows(execute, options) {
|
|
5
|
+
const selected = [];
|
|
6
|
+
for (let page = 1;; page += 1) {
|
|
7
|
+
const response = parseObject(await execute({
|
|
8
|
+
endpoint: `/repos/${options.repository}/actions/workflows?per_page=100&page=${page}`,
|
|
9
|
+
jq: '{workflows: [.workflows[] | {id, name, state}]}',
|
|
10
|
+
}), 'workflows response must be an object');
|
|
11
|
+
const pageWorkflows = parseArray(response['workflows'], 'workflows must be an array');
|
|
12
|
+
for (const value of pageWorkflows) {
|
|
13
|
+
const workflow = parseWorkflow(value);
|
|
14
|
+
if (isSelectedWorkflow(workflow, options.workflows))
|
|
15
|
+
selected.push(workflow);
|
|
16
|
+
}
|
|
17
|
+
if (pageWorkflows.length < 100)
|
|
18
|
+
break;
|
|
19
|
+
}
|
|
20
|
+
return selected.toSorted((a, b) => a.name.localeCompare(b.name) || a.id - b.id);
|
|
21
|
+
}
|
|
22
|
+
function runsQuery(filter, branch) {
|
|
23
|
+
if (filter.event === 'push')
|
|
24
|
+
return `event=push&branch=${encodeURIComponent(branch)}`;
|
|
25
|
+
return 'event=pull_request';
|
|
26
|
+
}
|
|
27
|
+
async function recentRuns(execute, workflow, options) {
|
|
28
|
+
const selected = [];
|
|
29
|
+
const filter = matchingWorkflowFilter(workflow.name, options.workflows);
|
|
30
|
+
/* v8 ignore next */
|
|
31
|
+
if (!filter)
|
|
32
|
+
return selected;
|
|
33
|
+
const query = runsQuery(filter, options.branch);
|
|
34
|
+
for (let page = 1;; page += 1) {
|
|
35
|
+
const response = parseObject(await execute({
|
|
36
|
+
endpoint: `/repos/${options.repository}/actions/workflows/${workflow.id}/runs?${query}&status=completed&per_page=100&page=${page}`,
|
|
37
|
+
jq: '{workflow_runs: [.workflow_runs[] | {id, name, event, conclusion, created_at, html_url, head_branch, pull_requests: [(.pull_requests // [])[] | {base: {ref: .base.ref}}]}]}',
|
|
38
|
+
}), 'workflow runs response must be an object');
|
|
39
|
+
const pageRuns = parseArray(response['workflow_runs'], 'workflow_runs must be an array');
|
|
40
|
+
const inScope = [];
|
|
41
|
+
for (const value of pageRuns) {
|
|
42
|
+
const run = parseRun(value);
|
|
43
|
+
if (isRunInScope(run, filter, options.branch))
|
|
44
|
+
inScope.push(run);
|
|
45
|
+
}
|
|
46
|
+
inScope.sort((a, b) => Date.parse(b.createdAt) - Date.parse(a.createdAt) || b.id - a.id);
|
|
47
|
+
for (const run of inScope) {
|
|
48
|
+
if (selected.length === options.recentCompletedRunHorizon)
|
|
49
|
+
break;
|
|
50
|
+
selected.push(run);
|
|
51
|
+
}
|
|
52
|
+
if (selected.length === options.recentCompletedRunHorizon || pageRuns.length < 100)
|
|
53
|
+
break;
|
|
54
|
+
}
|
|
55
|
+
return selected;
|
|
56
|
+
}
|
|
57
|
+
async function collectRunSamples(execute, options, run, samplesByKey) {
|
|
58
|
+
for (let page = 1;; page += 1) {
|
|
59
|
+
const response = parseObject(await execute({
|
|
60
|
+
endpoint: `/repos/${options.repository}/actions/runs/${run.id}/jobs?filter=latest&per_page=100&page=${page}`,
|
|
61
|
+
jq: '{jobs: [.jobs[] | {id, name, started_at, completed_at, conclusion, html_url}]}',
|
|
62
|
+
}), `jobs response for run ${run.id} must be an object`);
|
|
63
|
+
const jobs = parseArray(response['jobs'], `jobs for run ${run.id} must be an array`);
|
|
64
|
+
for (const value of jobs) {
|
|
65
|
+
const parsed = parseSample(value, run);
|
|
66
|
+
if (!parsed)
|
|
67
|
+
continue;
|
|
68
|
+
const key = `${run.name} / ${parsed.name}`;
|
|
69
|
+
const entry = samplesByKey.get(key) ?? {
|
|
70
|
+
workflow: run.name,
|
|
71
|
+
job: parsed.name,
|
|
72
|
+
samples: [],
|
|
73
|
+
};
|
|
74
|
+
if (entry.samples.length < options.sampleLimit)
|
|
75
|
+
entry.samples.push(parsed.sample);
|
|
76
|
+
samplesByKey.set(key, entry);
|
|
77
|
+
}
|
|
78
|
+
if (jobs.length < 100)
|
|
79
|
+
break;
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
export async function auditCiJobRuntime(execute, options) {
|
|
83
|
+
const resolved = resolveRuntimeAuditOptions(options);
|
|
84
|
+
const samplesByKey = new Map();
|
|
85
|
+
const seenRunIds = new Set();
|
|
86
|
+
for (const workflow of await discoverWorkflows(execute, resolved)) {
|
|
87
|
+
for (const run of await recentRuns(execute, workflow, resolved)) {
|
|
88
|
+
if (seenRunIds.has(run.id))
|
|
89
|
+
continue;
|
|
90
|
+
seenRunIds.add(run.id);
|
|
91
|
+
await collectRunSamples(execute, resolved, run, samplesByKey);
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
const results = buildRuntimeResults(samplesByKey, resolved);
|
|
95
|
+
return {
|
|
96
|
+
scope: {
|
|
97
|
+
repository: resolved.repository,
|
|
98
|
+
sampleLimit: resolved.sampleLimit,
|
|
99
|
+
recentCompletedRunHorizon: resolved.recentCompletedRunHorizon,
|
|
100
|
+
medianThresholdSeconds: resolved.medianThresholdSeconds,
|
|
101
|
+
hardCeilingSeconds: resolved.hardCeilingSeconds,
|
|
102
|
+
branch: resolved.branch,
|
|
103
|
+
},
|
|
104
|
+
...results,
|
|
105
|
+
};
|
|
106
|
+
}
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
export { auditCiJobRuntime } from './audit.mts';
|
|
2
|
+
export { violationOrder } from './results.mts';
|
|
3
|
+
export type { GhApiExecutor, GhApiRequest, RuntimeAuditResult, RuntimeJobResult, RuntimeSample, RuntimeViolationReason, } from './model.mts';
|
|
4
|
+
export { parseWorkflowNameMatch } from './scope.mts';
|
|
5
|
+
export type { RuntimeAuditOptions, RuntimeAuditWorkflowFilter } from './scope.mts';
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import type { GhApiExecutor } from './model.mts';
|
|
2
|
+
import type { RuntimeAuditOptions } from './scope.mts';
|
|
3
|
+
export type FixtureJob = {
|
|
4
|
+
id: number;
|
|
5
|
+
name: string;
|
|
6
|
+
started_at: string;
|
|
7
|
+
completed_at: string;
|
|
8
|
+
conclusion: 'success' | 'failure';
|
|
9
|
+
html_url: string;
|
|
10
|
+
};
|
|
11
|
+
export declare const exampleAuditOptions: Omit<RuntimeAuditOptions, 'repository'>;
|
|
12
|
+
export declare function makeJob(runId: number, name: string, seconds: number, queueMinutes?: number, conclusion?: FixtureJob['conclusion']): FixtureJob;
|
|
13
|
+
export declare function makeExecutor(runs: unknown[], jobsByRun: Readonly<Record<number, FixtureJob[]>>): GhApiExecutor;
|
|
14
|
+
export declare function makeRun(id: number, name: string, event: 'pull_request' | 'push', branch?: string, conclusion?: string, createdAt?: string, headBranch?: string | null): unknown;
|