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.
Files changed (69) hide show
  1. package/README.md +12 -0
  2. package/dist/cli/commands/allocate-browser-safe-ports.slot-fixtures.test-helpers.d.mts +6 -0
  3. package/dist/cli/commands/allocate-browser-safe-ports.slot-fixtures.test-helpers.mjs +103 -0
  4. package/dist/cli/commands/gha-runtime-audit.d.mts +3 -0
  5. package/dist/cli/commands/gha-runtime-audit.mjs +23 -0
  6. package/dist/cli/commands/github-output.test-helpers.d.mts +1 -0
  7. package/dist/cli/commands/github-output.test-helpers.mjs +32 -0
  8. package/dist/cli/commands/pnpm-install.d.mts +1 -0
  9. package/dist/cli/commands/pnpm-install.mjs +27 -0
  10. package/dist/cli/commands/spawn-script.d.mts +5 -0
  11. package/dist/cli/commands/spawn-script.mjs +7 -0
  12. package/dist/cli/commands/vitest-blob-manifest.d.mts +1 -0
  13. package/dist/cli/commands/vitest-blob-manifest.mjs +11 -0
  14. package/dist/cli/index.d.mts +1 -1
  15. package/dist/cli/index.mjs +43 -2
  16. package/dist/cli/parse-gha-runtime-audit.d.mts +13 -0
  17. package/dist/cli/parse-gha-runtime-audit.mjs +44 -0
  18. package/dist/cli/parse.d.mts +13 -1
  19. package/dist/cli/parse.mjs +17 -0
  20. package/dist/cli/script-path.d.mts +1 -0
  21. package/dist/cli/script-path.mjs +4 -0
  22. package/dist/cli/usage.d.mts +1 -1
  23. package/dist/cli/usage.mjs +22 -0
  24. package/dist/gha-runtime-audit/audit.d.mts +3 -0
  25. package/dist/gha-runtime-audit/audit.mjs +106 -0
  26. package/dist/gha-runtime-audit/index.d.mts +5 -0
  27. package/dist/gha-runtime-audit/index.mjs +3 -0
  28. package/dist/gha-runtime-audit/index.test-helpers.d.mts +14 -0
  29. package/dist/gha-runtime-audit/index.test-helpers.mjs +63 -0
  30. package/dist/gha-runtime-audit/model.d.mts +60 -0
  31. package/dist/gha-runtime-audit/model.mjs +83 -0
  32. package/dist/gha-runtime-audit/results.d.mts +15 -0
  33. package/dist/gha-runtime-audit/results.mjs +48 -0
  34. package/dist/gha-runtime-audit/scope.d.mts +29 -0
  35. package/dist/gha-runtime-audit/scope.mjs +39 -0
  36. package/dist/index.d.mts +8 -0
  37. package/dist/index.mjs +4 -0
  38. package/dist/pnpm-install/exec.d.mts +8 -0
  39. package/dist/pnpm-install/exec.mjs +88 -0
  40. package/dist/pnpm-install/index.d.mts +5 -0
  41. package/dist/pnpm-install/index.mjs +4 -0
  42. package/dist/pnpm-install/metadata.d.mts +5 -0
  43. package/dist/pnpm-install/metadata.mjs +91 -0
  44. package/dist/pnpm-install/pnpm-install-fixture.test-helpers.d.mts +22 -0
  45. package/dist/pnpm-install/pnpm-install-fixture.test-helpers.mjs +174 -0
  46. package/dist/pnpm-install/process.d.mts +11 -0
  47. package/dist/pnpm-install/process.mjs +81 -0
  48. package/dist/pnpm-install/release-age.d.mts +9 -0
  49. package/dist/pnpm-install/release-age.mjs +59 -0
  50. package/dist/pnpm-install/runner.d.mts +2 -0
  51. package/dist/pnpm-install/runner.mjs +106 -0
  52. package/dist/pnpm-install/support.d.mts +30 -0
  53. package/dist/pnpm-install/support.mjs +143 -0
  54. package/dist/shared-context/fake-git.test-helpers.d.mts +11 -0
  55. package/dist/shared-context/fake-git.test-helpers.mjs +59 -0
  56. package/dist/shared-context/index.d.mts +26 -0
  57. package/dist/shared-context/index.mjs +89 -0
  58. package/dist/vitest-blob-manifest/cli.d.mts +2 -0
  59. package/dist/vitest-blob-manifest/cli.mjs +33 -0
  60. package/dist/vitest-blob-manifest/index.d.mts +37 -0
  61. package/dist/vitest-blob-manifest/index.mjs +134 -0
  62. package/package.json +21 -1
  63. package/scripts/allocate-browser-safe-ports.py +661 -0
  64. package/scripts/fetch-forbidden-ports.json +7 -0
  65. package/scripts/gha/check-needs-results.sh +19 -0
  66. package/scripts/gha/download-with-diagnostics.sh +52 -0
  67. package/scripts/gha/host-pressure-diagnostics.sh +179 -0
  68. package/scripts/gha/write-github-multiline-output.sh +60 -0
  69. 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
+ }
@@ -1,3 +1,3 @@
1
1
  #!/usr/bin/env node
2
- export declare function runCli(argv?: readonly string[]): number;
2
+ export declare function runCli(argv?: readonly string[]): number | Promise<number>;
3
3
  export declare function isMainModule(metaUrl: string, argv1: string | undefined): boolean;
@@ -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 3 */
73
+ /* v8 ignore next 8 */
43
74
  if (isMainModule(import.meta.url, process.argv[1])) {
44
- process.exitCode = runCli();
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
+ }
@@ -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;
@@ -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;
@@ -0,0 +1,4 @@
1
+ import { fileURLToPath } from 'node:url';
2
+ export function packageScriptPath(relativeFromPackageRoot) {
3
+ return fileURLToPath(new URL(`../../${relativeFromPackageRoot}`, import.meta.url));
4
+ }
@@ -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;
@@ -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,3 @@
1
+ import { type GhApiExecutor, type RuntimeAuditResult } from './model.mts';
2
+ import { type RuntimeAuditOptions } from './scope.mts';
3
+ export declare function auditCiJobRuntime(execute: GhApiExecutor, options: RuntimeAuditOptions): Promise<RuntimeAuditResult>;
@@ -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,3 @@
1
+ export { auditCiJobRuntime } from './audit.mjs';
2
+ export { violationOrder } from './results.mjs';
3
+ export { parseWorkflowNameMatch } from './scope.mjs';
@@ -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;