vouchington-tooling 0.19.2 → 0.21.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +14 -9
- package/dist/cli/commands/gha-runtime-audit.mjs +6 -0
- package/dist/cli/commands/retrospective-facts.mjs +20 -7
- package/dist/cli/index.mjs +4 -1
- package/dist/cli/parse-gha-runtime-audit.d.mts +2 -0
- package/dist/cli/parse-gha-runtime-audit.mjs +33 -1
- package/dist/cli/parse.d.mts +7 -0
- package/dist/cli/parse.mjs +15 -0
- package/dist/cli/usage.d.mts +3 -1
- package/dist/cli/usage.mjs +44 -1
- package/dist/dependency-license-policy/audit-directory.d.mts +28 -0
- package/dist/dependency-license-policy/audit-directory.mjs +115 -0
- package/dist/dependency-license-policy/audit-signals.d.mts +12 -0
- package/dist/dependency-license-policy/audit-signals.mjs +44 -0
- package/dist/dependency-license-policy/audit-store.d.mts +12 -0
- package/dist/dependency-license-policy/audit-store.mjs +32 -0
- package/dist/dependency-license-policy/collect.d.mts +3 -1
- package/dist/dependency-license-policy/collect.mjs +16 -21
- package/dist/dependency-license-policy/execute-pnpm.d.mts +10 -0
- package/dist/dependency-license-policy/execute-pnpm.mjs +83 -0
- package/dist/dependency-license-policy/types.d.mts +11 -9
- package/dist/dependency-license-policy/workspace.d.mts +3 -1
- package/dist/dependency-license-policy/workspace.mjs +12 -5
- package/dist/gha-runtime-audit/audit.mjs +12 -4
- package/dist/gha-runtime-audit/family.d.mts +7 -0
- package/dist/gha-runtime-audit/family.mjs +79 -0
- package/dist/gha-runtime-audit/index.test-helpers.d.mts +5 -0
- package/dist/gha-runtime-audit/model.d.mts +11 -1
- package/dist/gha-runtime-audit/model.mjs +26 -0
- package/dist/gha-runtime-audit/scope.d.mts +2 -0
- package/dist/gha-runtime-audit/scope.mjs +16 -1
- package/dist/pnpm-install/support.mjs +1 -1
- package/package.json +1 -1
- package/scripts/allocate-browser-safe-ports.py +2 -583
- package/dist/cli/commands/allocate-browser-safe-ports.slot-fixtures.test-helpers.d.mts +0 -6
- package/dist/cli/commands/allocate-browser-safe-ports.slot-fixtures.test-helpers.mjs +0 -103
|
@@ -1,10 +1,13 @@
|
|
|
1
|
-
import { spawnSync } from 'node:child_process';
|
|
2
1
|
import { readFileSync } from 'node:fs';
|
|
3
2
|
import { join } from 'node:path';
|
|
3
|
+
import { withAuditSignalCleanup } from './audit-signals.mjs';
|
|
4
|
+
import { ensurePrivateLicenseAuditStore, licenseAuditStoreDirectory } from './audit-store.mjs';
|
|
5
|
+
import { executePnpm } from './execute-pnpm.mjs';
|
|
4
6
|
import { parsePnpmLicenseReport } from './report.mjs';
|
|
5
7
|
import { preparePnpmLicenseAuditWorkspace } from './workspace.mjs';
|
|
6
8
|
const DEFAULT_OPTIONS = {
|
|
7
|
-
|
|
9
|
+
ensureStore: ensurePrivateLicenseAuditStore,
|
|
10
|
+
execute: executePnpm,
|
|
8
11
|
prepareWorkspace: preparePnpmLicenseAuditWorkspace,
|
|
9
12
|
readFile: readFileSync,
|
|
10
13
|
};
|
|
@@ -19,22 +22,17 @@ function assertCommandSucceeded(label, result) {
|
|
|
19
22
|
}
|
|
20
23
|
}
|
|
21
24
|
/** Collects licenses for every platform represented in a pnpm lockfile. */
|
|
22
|
-
export function collectPnpmLicenseReport(repoRoot, options = {}) {
|
|
23
|
-
const { execute, prepareWorkspace, readFile } = { ...DEFAULT_OPTIONS, ...options };
|
|
24
|
-
const
|
|
25
|
-
const
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
const storeConfig = `--config.store-dir=${
|
|
29
|
-
const
|
|
30
|
-
|
|
31
|
-
encoding: 'utf8',
|
|
32
|
-
});
|
|
25
|
+
export async function collectPnpmLicenseReport(repoRoot, options = {}) {
|
|
26
|
+
const { ensureStore, execute, prepareWorkspace, readFile } = { ...DEFAULT_OPTIONS, ...options };
|
|
27
|
+
const auditWorkspace = prepareWorkspace(repoRoot, readFile(join(repoRoot, 'pnpm-lock.yaml'), 'utf8'), readFile(join(repoRoot, 'pnpm-workspace.yaml'), 'utf8'));
|
|
28
|
+
const storeDir = options.storeDir ?? licenseAuditStoreDirectory();
|
|
29
|
+
return withAuditSignalCleanup(auditWorkspace.cleanup, async (signal) => {
|
|
30
|
+
ensureStore(storeDir);
|
|
31
|
+
const storeConfig = `--config.store-dir=${storeDir}`;
|
|
32
|
+
const commandOptions = { cwd: auditWorkspace.cwd, encoding: 'utf8', signal };
|
|
33
|
+
const fetchResult = await execute('pnpm', [storeConfig, 'fetch', '--ignore-scripts'], commandOptions);
|
|
33
34
|
assertCommandSucceeded('pnpm fetch', fetchResult);
|
|
34
|
-
const result = execute('pnpm', [storeConfig, 'licenses', 'list', '--json'],
|
|
35
|
-
cwd: auditWorkspace.cwd,
|
|
36
|
-
encoding: 'utf8',
|
|
37
|
-
});
|
|
35
|
+
const result = await execute('pnpm', [storeConfig, 'licenses', 'list', '--json'], commandOptions);
|
|
38
36
|
assertCommandSucceeded('pnpm licenses list --json', result);
|
|
39
37
|
try {
|
|
40
38
|
return parsePnpmLicenseReport(JSON.parse(result.stdout));
|
|
@@ -44,8 +42,5 @@ export function collectPnpmLicenseReport(repoRoot, options = {}) {
|
|
|
44
42
|
cause: error,
|
|
45
43
|
});
|
|
46
44
|
}
|
|
47
|
-
}
|
|
48
|
-
finally {
|
|
49
|
-
auditWorkspace.cleanup();
|
|
50
|
-
}
|
|
45
|
+
});
|
|
51
46
|
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import type { Readable } from 'node:stream';
|
|
2
|
+
import type { PnpmExecutor } from './types.mts';
|
|
3
|
+
export declare function signalFromAbort(signal: AbortSignal): NodeJS.Signals;
|
|
4
|
+
export declare function stopChildProcess(child: {
|
|
5
|
+
kill: (signal?: NodeJS.Signals) => boolean;
|
|
6
|
+
pid?: number | undefined;
|
|
7
|
+
}, signal: NodeJS.Signals): void;
|
|
8
|
+
export declare function collectChildStream(stream: Readable | null, chunks: string[], onOverflow: () => void): void;
|
|
9
|
+
/** Runs pnpm in its own process group so a signal can reach it and its children. */
|
|
10
|
+
export declare const executePnpm: PnpmExecutor;
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import { spawn } from 'node:child_process';
|
|
2
|
+
const MAX_BUFFER_BYTES = 1024 * 1024;
|
|
3
|
+
export function signalFromAbort(signal) {
|
|
4
|
+
if (signal.reason === 'SIGINT' || signal.reason === 'SIGTERM' || signal.reason === 'SIGHUP') {
|
|
5
|
+
return signal.reason;
|
|
6
|
+
}
|
|
7
|
+
return 'SIGTERM';
|
|
8
|
+
}
|
|
9
|
+
export function stopChildProcess(child, signal) {
|
|
10
|
+
if (child.pid === undefined) {
|
|
11
|
+
child.kill(signal);
|
|
12
|
+
return;
|
|
13
|
+
}
|
|
14
|
+
try {
|
|
15
|
+
process.kill(-child.pid, signal);
|
|
16
|
+
}
|
|
17
|
+
catch {
|
|
18
|
+
child.kill(signal);
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
export function collectChildStream(stream, chunks, onOverflow) {
|
|
22
|
+
if (stream === null)
|
|
23
|
+
throw new Error('license audit child is missing a stdio pipe');
|
|
24
|
+
stream.setEncoding('utf8');
|
|
25
|
+
stream.on('data', (chunk) => {
|
|
26
|
+
chunks.push(chunk);
|
|
27
|
+
if (Buffer.byteLength(chunks.join('')) > MAX_BUFFER_BYTES)
|
|
28
|
+
onOverflow();
|
|
29
|
+
});
|
|
30
|
+
}
|
|
31
|
+
function finishOnce(settle) {
|
|
32
|
+
let settled = false;
|
|
33
|
+
return (result) => {
|
|
34
|
+
if (settled)
|
|
35
|
+
return;
|
|
36
|
+
settled = true;
|
|
37
|
+
settle(result);
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
/** Runs pnpm in its own process group so a signal can reach it and its children. */
|
|
41
|
+
export const executePnpm = (command, args, options) => new Promise((resolve) => {
|
|
42
|
+
const child = spawn(command, args, {
|
|
43
|
+
cwd: options.cwd,
|
|
44
|
+
detached: true,
|
|
45
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
46
|
+
});
|
|
47
|
+
const stdout = [];
|
|
48
|
+
const stderr = [];
|
|
49
|
+
let overflow = false;
|
|
50
|
+
const stop = () => {
|
|
51
|
+
stopChildProcess(child, overflow ? 'SIGKILL' : signalFromAbort(options.signal));
|
|
52
|
+
};
|
|
53
|
+
if (options.signal.aborted)
|
|
54
|
+
stop();
|
|
55
|
+
else
|
|
56
|
+
options.signal.addEventListener('abort', stop, { once: true });
|
|
57
|
+
const markOverflow = () => {
|
|
58
|
+
overflow = true;
|
|
59
|
+
stop();
|
|
60
|
+
};
|
|
61
|
+
collectChildStream(child.stdout, stdout, markOverflow);
|
|
62
|
+
collectChildStream(child.stderr, stderr, markOverflow);
|
|
63
|
+
const finish = finishOnce((result) => {
|
|
64
|
+
options.signal.removeEventListener('abort', stop);
|
|
65
|
+
resolve(result);
|
|
66
|
+
});
|
|
67
|
+
child.once('error', (error) => {
|
|
68
|
+
finish({ error, status: null, stderr: stderr.join(''), stdout: stdout.join('') });
|
|
69
|
+
});
|
|
70
|
+
child.once('close', (status) => {
|
|
71
|
+
const result = { status, stderr: stderr.join(''), stdout: stdout.join('') };
|
|
72
|
+
if (!overflow) {
|
|
73
|
+
finish(result);
|
|
74
|
+
return;
|
|
75
|
+
}
|
|
76
|
+
finish({
|
|
77
|
+
...result,
|
|
78
|
+
error: Object.assign(new Error('child output maxBuffer exceeded'), {
|
|
79
|
+
code: 'ERR_CHILD_PROCESS_STDIO_MAXBUFFER',
|
|
80
|
+
}),
|
|
81
|
+
});
|
|
82
|
+
});
|
|
83
|
+
});
|
|
@@ -4,15 +4,17 @@ export interface PnpmLicenseReportEntry {
|
|
|
4
4
|
}
|
|
5
5
|
/** Shape emitted by `pnpm licenses list --json`. */
|
|
6
6
|
export type PnpmLicenseReport = Record<string, PnpmLicenseReportEntry[]>;
|
|
7
|
-
export
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
7
|
+
export interface PnpmCommandResult {
|
|
8
|
+
readonly error?: Error;
|
|
9
|
+
readonly status: number | null;
|
|
10
|
+
readonly stderr: string;
|
|
11
|
+
readonly stdout: string;
|
|
12
|
+
}
|
|
13
|
+
export type PnpmExecutor = (command: string, args: readonly string[], options: {
|
|
14
|
+
readonly cwd: string;
|
|
15
|
+
readonly encoding: 'utf8';
|
|
16
|
+
readonly signal: AbortSignal;
|
|
17
|
+
}) => PnpmCommandResult | Promise<PnpmCommandResult>;
|
|
16
18
|
export interface DependencyLicenseAllowlistEntry {
|
|
17
19
|
/** Exact SPDX atom allowed by this entry. */
|
|
18
20
|
readonly licenseId: string;
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { type LicenseAuditWorkspaceOptions } from './audit-directory.mts';
|
|
1
2
|
export interface PnpmLicenseAuditWorkspace {
|
|
2
3
|
readonly cleanup: () => void;
|
|
3
4
|
readonly cwd: string;
|
|
@@ -19,4 +20,5 @@ export declare function renderPnpmLicenseAuditFiles(lockfileSource: string, work
|
|
|
19
20
|
lockfile: string;
|
|
20
21
|
workspace: string;
|
|
21
22
|
}): PnpmLicenseAuditFiles;
|
|
22
|
-
export declare function
|
|
23
|
+
export declare function licenseAuditParentDirectory(directory?: string): string;
|
|
24
|
+
export declare function preparePnpmLicenseAuditWorkspace(repoRoot: string, lockfileSource: string, workspaceSource: string, options?: LicenseAuditWorkspaceOptions): PnpmLicenseAuditWorkspace;
|
|
@@ -1,7 +1,8 @@
|
|
|
1
|
-
import { copyFileSync, existsSync, mkdtempSync,
|
|
1
|
+
import { copyFileSync, existsSync, mkdtempSync, writeFileSync } from 'node:fs';
|
|
2
2
|
import { tmpdir } from 'node:os';
|
|
3
3
|
import { join } from 'node:path';
|
|
4
4
|
import { isMap, parseDocument, stringify as stringifyYaml } from 'yaml';
|
|
5
|
+
import { LICENSE_AUDIT_DIRECTORY_PREFIX, reclaimStaleLicenseAuditDirectories, removeAuditDirectory, writeAuditPid, } from './audit-directory.mjs';
|
|
5
6
|
const PLATFORM_KEYS = ['os', 'cpu', 'libc'];
|
|
6
7
|
function parseYamlDocument(source, path) {
|
|
7
8
|
const document = parseDocument(source);
|
|
@@ -71,9 +72,15 @@ export function renderPnpmLicenseAuditFiles(lockfileSource, workspaceSource, pat
|
|
|
71
72
|
workspace: stringifyYaml({ ...workspace, packages: [], supportedArchitectures }),
|
|
72
73
|
};
|
|
73
74
|
}
|
|
74
|
-
export function
|
|
75
|
-
|
|
75
|
+
export function licenseAuditParentDirectory(directory) {
|
|
76
|
+
return directory ?? tmpdir();
|
|
77
|
+
}
|
|
78
|
+
export function preparePnpmLicenseAuditWorkspace(repoRoot, lockfileSource, workspaceSource, options = {}) {
|
|
79
|
+
const directory = licenseAuditParentDirectory(options.directory);
|
|
80
|
+
reclaimStaleLicenseAuditDirectories(directory, options);
|
|
81
|
+
const auditRoot = mkdtempSync(join(directory, LICENSE_AUDIT_DIRECTORY_PREFIX));
|
|
76
82
|
try {
|
|
83
|
+
writeAuditPid(auditRoot, options.pid);
|
|
77
84
|
copyFileSync(join(repoRoot, 'package.json'), join(auditRoot, 'package.json'));
|
|
78
85
|
const npmrc = join(repoRoot, '.npmrc');
|
|
79
86
|
if (existsSync(npmrc))
|
|
@@ -86,11 +93,11 @@ export function preparePnpmLicenseAuditWorkspace(repoRoot, lockfileSource, works
|
|
|
86
93
|
writeFileSync(join(auditRoot, 'pnpm-workspace.yaml'), files.workspace, 'utf8');
|
|
87
94
|
}
|
|
88
95
|
catch (error) {
|
|
89
|
-
|
|
96
|
+
removeAuditDirectory(auditRoot);
|
|
90
97
|
throw error;
|
|
91
98
|
}
|
|
92
99
|
return {
|
|
93
100
|
cwd: auditRoot,
|
|
94
|
-
cleanup: () =>
|
|
101
|
+
cleanup: () => removeAuditDirectory(auditRoot),
|
|
95
102
|
};
|
|
96
103
|
}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { parseArray, parseObject, parseRun, parseSample, parseWorkflow, } from './model.mjs';
|
|
2
|
-
import {
|
|
2
|
+
import { buildFamilyResults } from './family.mjs';
|
|
3
|
+
import { buildRuntimeResults, violationOrder } from './results.mjs';
|
|
3
4
|
import { isRunInScope, isSelectedWorkflow, matchingWorkflowFilter, resolveRuntimeAuditOptions, } from './scope.mjs';
|
|
4
5
|
async function discoverWorkflows(execute, options) {
|
|
5
6
|
const selected = [];
|
|
@@ -58,7 +59,7 @@ async function collectRunSamples(execute, options, run, samplesByKey) {
|
|
|
58
59
|
for (let page = 1;; page += 1) {
|
|
59
60
|
const response = parseObject(await execute({
|
|
60
61
|
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
|
+
jq: '{jobs: [.jobs[] | {id, name, started_at, completed_at, conclusion, html_url, steps: [(.steps // [])[] | {name, started_at, completed_at}]}]}',
|
|
62
63
|
}), `jobs response for run ${run.id} must be an object`);
|
|
63
64
|
const jobs = parseArray(response['jobs'], `jobs for run ${run.id} must be an array`);
|
|
64
65
|
for (const value of jobs) {
|
|
@@ -91,16 +92,23 @@ export async function auditCiJobRuntime(execute, options) {
|
|
|
91
92
|
await collectRunSamples(execute, resolved, run, samplesByKey);
|
|
92
93
|
}
|
|
93
94
|
}
|
|
94
|
-
const results = buildRuntimeResults(samplesByKey, resolved);
|
|
95
95
|
return {
|
|
96
96
|
scope: {
|
|
97
97
|
repository: resolved.repository,
|
|
98
98
|
sampleLimit: resolved.sampleLimit,
|
|
99
99
|
recentCompletedRunHorizon: resolved.recentCompletedRunHorizon,
|
|
100
100
|
medianThresholdSeconds: resolved.medianThresholdSeconds,
|
|
101
|
+
medianFloorSeconds: resolved.medianFloorSeconds,
|
|
101
102
|
hardCeilingSeconds: resolved.hardCeilingSeconds,
|
|
102
103
|
branch: resolved.branch,
|
|
103
104
|
},
|
|
104
|
-
...
|
|
105
|
+
...combineResults(samplesByKey, resolved),
|
|
105
106
|
};
|
|
106
107
|
}
|
|
108
|
+
function combineResults(samplesByKey, resolved) {
|
|
109
|
+
const perJob = buildRuntimeResults(samplesByKey, resolved);
|
|
110
|
+
const jobs = [...perJob.jobs, ...buildFamilyResults(samplesByKey, resolved)].sort((left, right) => left.key.localeCompare(right.key));
|
|
111
|
+
const violations = jobs.filter((job) => job.reasons.length > 0);
|
|
112
|
+
violations.sort(violationOrder);
|
|
113
|
+
return { jobs, violations };
|
|
114
|
+
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import type { RuntimeJobResult } from './model.mts';
|
|
2
|
+
import type { RuntimeSamplesByJob } from './results.mts';
|
|
3
|
+
export type FamilyThresholds = {
|
|
4
|
+
medianFloorSeconds: number | null;
|
|
5
|
+
medianThresholdSeconds: number;
|
|
6
|
+
};
|
|
7
|
+
export declare function buildFamilyResults(samplesByKey: ReadonlyMap<string, RuntimeSamplesByJob>, thresholds: FamilyThresholds): RuntimeJobResult[];
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
const SETUP_STEP = /checkout|setup-|action-setup|cache|install|download|^post /i;
|
|
2
|
+
export function buildFamilyResults(samplesByKey, thresholds) {
|
|
3
|
+
if (thresholds.medianFloorSeconds === null)
|
|
4
|
+
return [];
|
|
5
|
+
const families = new Map();
|
|
6
|
+
for (const entry of samplesByKey.values()) {
|
|
7
|
+
const family = entry.job.replace(/ \([^)]*\)$/, '');
|
|
8
|
+
const key = `${entry.workflow} / ${family}`;
|
|
9
|
+
const bucket = families.get(key) ?? {
|
|
10
|
+
workflow: entry.workflow,
|
|
11
|
+
family,
|
|
12
|
+
jobs: new Set(),
|
|
13
|
+
samples: [],
|
|
14
|
+
};
|
|
15
|
+
bucket.jobs.add(entry.job);
|
|
16
|
+
for (const sample of entry.samples)
|
|
17
|
+
bucket.samples.push(sample);
|
|
18
|
+
families.set(key, bucket);
|
|
19
|
+
}
|
|
20
|
+
const results = [];
|
|
21
|
+
for (const [key, bucket] of families) {
|
|
22
|
+
if (bucket.jobs.size < 2)
|
|
23
|
+
continue;
|
|
24
|
+
results.push(familyResult(key, bucket, thresholds.medianFloorSeconds, thresholds));
|
|
25
|
+
}
|
|
26
|
+
return results;
|
|
27
|
+
}
|
|
28
|
+
function familyResult(key, bucket, floorSeconds, thresholds) {
|
|
29
|
+
const durations = bucket.samples.map((sample) => sample.durationSeconds);
|
|
30
|
+
const medianSeconds = medianDuration(durations);
|
|
31
|
+
const belowFloor = medianSeconds < floorSeconds;
|
|
32
|
+
const reasons = [];
|
|
33
|
+
if (belowFloor)
|
|
34
|
+
reasons.push('family-median-below-floor');
|
|
35
|
+
return {
|
|
36
|
+
key,
|
|
37
|
+
workflow: bucket.workflow,
|
|
38
|
+
job: bucket.family,
|
|
39
|
+
sampleCount: bucket.samples.length,
|
|
40
|
+
medianSeconds,
|
|
41
|
+
maximumSeconds: Math.max(...durations),
|
|
42
|
+
reasons,
|
|
43
|
+
samples: bucket.samples,
|
|
44
|
+
shardCount: bucket.jobs.size,
|
|
45
|
+
setupShare: setupShare(bucket.samples),
|
|
46
|
+
...(belowFloor
|
|
47
|
+
? {
|
|
48
|
+
suggestedShardCount: suggestedShardCount(bucket.jobs.size, medianSeconds, thresholds.medianThresholdSeconds),
|
|
49
|
+
}
|
|
50
|
+
: {}),
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
function medianDuration(durations) {
|
|
54
|
+
const sorted = [...durations].sort((left, right) => left - right);
|
|
55
|
+
const mid = Math.floor(sorted.length / 2);
|
|
56
|
+
if (sorted.length % 2 === 1)
|
|
57
|
+
return sorted[mid];
|
|
58
|
+
return (sorted[mid - 1] + sorted[mid]) / 2;
|
|
59
|
+
}
|
|
60
|
+
function setupShare(samples) {
|
|
61
|
+
let setupSeconds = 0;
|
|
62
|
+
let totalSeconds = 0;
|
|
63
|
+
for (const sample of samples) {
|
|
64
|
+
for (const step of sample.steps ?? []) {
|
|
65
|
+
totalSeconds += step.durationSeconds;
|
|
66
|
+
if (isSetupStep(step.name))
|
|
67
|
+
setupSeconds += step.durationSeconds;
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
if (totalSeconds === 0)
|
|
71
|
+
return null;
|
|
72
|
+
return setupSeconds / totalSeconds;
|
|
73
|
+
}
|
|
74
|
+
function isSetupStep(name) {
|
|
75
|
+
return SETUP_STEP.test(name);
|
|
76
|
+
}
|
|
77
|
+
function suggestedShardCount(shardCount, medianSeconds, medianCeilingSeconds) {
|
|
78
|
+
return Math.max(1, Math.round((shardCount * medianSeconds) / medianCeilingSeconds));
|
|
79
|
+
}
|
|
@@ -7,6 +7,11 @@ export type FixtureJob = {
|
|
|
7
7
|
completed_at: string;
|
|
8
8
|
conclusion: 'success' | 'failure';
|
|
9
9
|
html_url: string;
|
|
10
|
+
steps?: Array<{
|
|
11
|
+
name: string;
|
|
12
|
+
started_at: string | null;
|
|
13
|
+
completed_at: string | null;
|
|
14
|
+
}>;
|
|
10
15
|
};
|
|
11
16
|
export declare const exampleAuditOptions: Omit<RuntimeAuditOptions, 'repository'>;
|
|
12
17
|
export declare function makeJob(runId: number, name: string, seconds: number, queueMinutes?: number, conclusion?: FixtureJob['conclusion']): FixtureJob;
|
|
@@ -18,6 +18,10 @@ export interface RunRecord {
|
|
|
18
18
|
headBranch: string | null;
|
|
19
19
|
pullRequestBaseBranches: string[];
|
|
20
20
|
}
|
|
21
|
+
interface RuntimeStepSample {
|
|
22
|
+
name: string;
|
|
23
|
+
durationSeconds: number;
|
|
24
|
+
}
|
|
21
25
|
export interface RuntimeSample {
|
|
22
26
|
runId: number;
|
|
23
27
|
runUrl: string;
|
|
@@ -26,8 +30,9 @@ export interface RuntimeSample {
|
|
|
26
30
|
startedAt: string;
|
|
27
31
|
completedAt: string;
|
|
28
32
|
durationSeconds: number;
|
|
33
|
+
steps?: RuntimeStepSample[];
|
|
29
34
|
}
|
|
30
|
-
export type RuntimeViolationReason = 'sample-at-or-above-hard-ceiling' | 'five-sample-median-above-threshold';
|
|
35
|
+
export type RuntimeViolationReason = 'sample-at-or-above-hard-ceiling' | 'five-sample-median-above-threshold' | 'family-median-below-floor';
|
|
31
36
|
export interface RuntimeJobResult {
|
|
32
37
|
key: string;
|
|
33
38
|
workflow: string;
|
|
@@ -37,6 +42,9 @@ export interface RuntimeJobResult {
|
|
|
37
42
|
maximumSeconds: number;
|
|
38
43
|
reasons: RuntimeViolationReason[];
|
|
39
44
|
samples: RuntimeSample[];
|
|
45
|
+
shardCount?: number;
|
|
46
|
+
setupShare?: number | null;
|
|
47
|
+
suggestedShardCount?: number;
|
|
40
48
|
}
|
|
41
49
|
export interface RuntimeAuditResult {
|
|
42
50
|
scope: {
|
|
@@ -44,6 +52,7 @@ export interface RuntimeAuditResult {
|
|
|
44
52
|
sampleLimit: number;
|
|
45
53
|
recentCompletedRunHorizon: number;
|
|
46
54
|
medianThresholdSeconds: number;
|
|
55
|
+
medianFloorSeconds: number | null;
|
|
47
56
|
hardCeilingSeconds: number;
|
|
48
57
|
branch: string;
|
|
49
58
|
};
|
|
@@ -58,3 +67,4 @@ export declare function parseSample(value: unknown, run: RunRecord): {
|
|
|
58
67
|
name: string;
|
|
59
68
|
sample: RuntimeSample;
|
|
60
69
|
} | null;
|
|
70
|
+
export {};
|
|
@@ -78,6 +78,32 @@ export function parseSample(value, run) {
|
|
|
78
78
|
startedAt,
|
|
79
79
|
completedAt,
|
|
80
80
|
durationSeconds,
|
|
81
|
+
...jobSteps(job['steps'], run.id),
|
|
81
82
|
},
|
|
82
83
|
};
|
|
83
84
|
}
|
|
85
|
+
function jobSteps(value, runId) {
|
|
86
|
+
if (value === undefined)
|
|
87
|
+
return {};
|
|
88
|
+
const steps = parseArray(value, `job in run ${runId} steps must be an array`);
|
|
89
|
+
const parsed = [];
|
|
90
|
+
for (const [index, entry] of steps.entries()) {
|
|
91
|
+
const step = readStep(entry, runId, index);
|
|
92
|
+
if (step)
|
|
93
|
+
parsed.push(step);
|
|
94
|
+
}
|
|
95
|
+
return { steps: parsed };
|
|
96
|
+
}
|
|
97
|
+
function readStep(value, runId, index) {
|
|
98
|
+
const detail = `job in run ${runId} steps[${index}]`;
|
|
99
|
+
const step = parseObject(value, `${detail} must be an object`);
|
|
100
|
+
const name = parseString(step['name'], `${detail}.name must be a non-empty string`);
|
|
101
|
+
if (step['started_at'] == null || step['completed_at'] == null)
|
|
102
|
+
return undefined;
|
|
103
|
+
const startedAt = parseTimestamp(step['started_at'], `${detail}.started_at`);
|
|
104
|
+
const completedAt = parseTimestamp(step['completed_at'], `${detail}.completed_at`);
|
|
105
|
+
const durationSeconds = (Date.parse(completedAt) - Date.parse(startedAt)) / 1000;
|
|
106
|
+
if (durationSeconds < 0)
|
|
107
|
+
malformed(`${detail} has an invalid execution interval`);
|
|
108
|
+
return { name, durationSeconds };
|
|
109
|
+
}
|
|
@@ -11,6 +11,7 @@ export type RuntimeAuditOptions = {
|
|
|
11
11
|
sampleLimit?: number;
|
|
12
12
|
recentCompletedRunHorizon?: number;
|
|
13
13
|
medianThresholdSeconds?: number;
|
|
14
|
+
medianFloorSeconds?: number;
|
|
14
15
|
hardCeilingSeconds?: number;
|
|
15
16
|
};
|
|
16
17
|
export type ResolvedRuntimeAuditOptions = {
|
|
@@ -20,6 +21,7 @@ export type ResolvedRuntimeAuditOptions = {
|
|
|
20
21
|
sampleLimit: number;
|
|
21
22
|
recentCompletedRunHorizon: number;
|
|
22
23
|
medianThresholdSeconds: number;
|
|
24
|
+
medianFloorSeconds: number | null;
|
|
23
25
|
hardCeilingSeconds: number;
|
|
24
26
|
};
|
|
25
27
|
export declare function parseWorkflowNameMatch(value: string): WorkflowNameMatch;
|
|
@@ -14,16 +14,31 @@ export function resolveRuntimeAuditOptions(options) {
|
|
|
14
14
|
if (options.workflows.length === 0) {
|
|
15
15
|
throw new Error('At least one workflow filter is required');
|
|
16
16
|
}
|
|
17
|
+
const medianThresholdSeconds = options.medianThresholdSeconds ?? 360;
|
|
18
|
+
const medianFloorSeconds = options.medianFloorSeconds ?? null;
|
|
19
|
+
assertPositiveInteger(medianThresholdSeconds, 'median ceiling');
|
|
20
|
+
if (medianFloorSeconds !== null) {
|
|
21
|
+
assertPositiveInteger(medianFloorSeconds, 'median floor');
|
|
22
|
+
if (medianFloorSeconds >= medianThresholdSeconds) {
|
|
23
|
+
throw new Error('median floor must be below the median ceiling');
|
|
24
|
+
}
|
|
25
|
+
}
|
|
17
26
|
return {
|
|
18
27
|
repository: options.repository,
|
|
19
28
|
workflows: options.workflows,
|
|
20
29
|
branch: options.branch ?? 'main',
|
|
21
30
|
sampleLimit: options.sampleLimit ?? 5,
|
|
22
31
|
recentCompletedRunHorizon: options.recentCompletedRunHorizon ?? 10,
|
|
23
|
-
medianThresholdSeconds
|
|
32
|
+
medianThresholdSeconds,
|
|
33
|
+
medianFloorSeconds,
|
|
24
34
|
hardCeilingSeconds: options.hardCeilingSeconds ?? 600,
|
|
25
35
|
};
|
|
26
36
|
}
|
|
37
|
+
function assertPositiveInteger(value, label) {
|
|
38
|
+
if (!Number.isSafeInteger(value) || value <= 0) {
|
|
39
|
+
throw new Error(`${label} must be a positive integer`);
|
|
40
|
+
}
|
|
41
|
+
}
|
|
27
42
|
export function matchingWorkflowFilter(name, filters) {
|
|
28
43
|
return filters.find((filter) => matchesWorkflowName(name, filter.name));
|
|
29
44
|
}
|
|
@@ -2,7 +2,7 @@ import { readFile, realpath } from 'node:fs/promises';
|
|
|
2
2
|
import path from 'node:path';
|
|
3
3
|
const commonInstallArgs = [
|
|
4
4
|
'--prefer-offline',
|
|
5
|
-
'--prod
|
|
5
|
+
'--no-prod',
|
|
6
6
|
'--config.disallow-workspace-cycles=false',
|
|
7
7
|
];
|
|
8
8
|
export const baseInstallArgs = ['install', '--frozen-lockfile', ...commonInstallArgs];
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "vouchington-tooling",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.21.0",
|
|
4
4
|
"description": "Vouchington CLI and extractable tooling libraries.",
|
|
5
5
|
"homepage": "https://github.com/vouchington/vouchington-tooling/tree/main/packages/vouchington-tooling#readme",
|
|
6
6
|
"bugs": {
|