mirror of
https://github.com/pnpm/pnpm.git
synced 2026-06-28 09:55:39 -04:00
ci: track test suite durations in bencher (#12404)
## Summary Adds CI duration tracking for the `pnpm-ci-performance` Bencher project. Tracked Rust testbeds and benchmarks: - `pacquet.ubuntu`, `pacquet.windows`, `pacquet.macos` -> `tests.all` - `pnpr.ubuntu`, `pnpr.windows`, `pnpr.macos` -> `tests.all` Tracked pnpm testbeds and benchmarks for full test runs: - `pnpm.ubuntu.node22`, `pnpm.ubuntu.node24`, `pnpm.ubuntu.node26` -> `tests.all`, `tests.cli` - `pnpm.windows.node22`, `pnpm.windows.node24`, `pnpm.windows.node26` -> `tests.all`, `tests.cli` The test workflows produce Bencher-compatible JSON artifacts without receiving `BENCHER_API_TOKEN`. A separate `workflow_run` workflow downloads those artifacts only for same-repository runs, validates their metadata, and uploads from trusted workflow code using the existing `BENCHER_API_TOKEN` secret. The pnpm CLI e2e duration is extracted from `pnpm run --report-summary` output during the same full-test execution, so the CLI e2e suite is not run a second time.
This commit is contained in:
67
.github/scripts/bench-output-path.mjs
vendored
Normal file
67
.github/scripts/bench-output-path.mjs
vendored
Normal file
@@ -0,0 +1,67 @@
|
||||
import { existsSync, lstatSync, mkdirSync, realpathSync } from 'node:fs'
|
||||
import { dirname, isAbsolute, relative, resolve, sep } from 'node:path'
|
||||
|
||||
export function resolveBenchOutputPath (output) {
|
||||
const benchDir = resolve('.bench')
|
||||
const outputPath = resolve(output)
|
||||
const relativePath = relative(benchDir, outputPath)
|
||||
if (relativePath === '' || isOutside(relativePath)) {
|
||||
throw new Error(`Output path must be under .bench/: ${output}`)
|
||||
}
|
||||
ensureBenchDir(benchDir, output)
|
||||
assertNotSymlink(outputPath, output)
|
||||
assertNoSymlinkAncestors(benchDir, dirname(outputPath), output)
|
||||
|
||||
const canonicalBenchDir = realpathSync(benchDir)
|
||||
const canonicalParent = realpathSync(nearestExistingAncestor(dirname(outputPath)))
|
||||
if (!isSameOrChild(canonicalBenchDir, canonicalParent)) {
|
||||
throw new Error(`Output path must be under .bench/: ${output}`)
|
||||
}
|
||||
return outputPath
|
||||
}
|
||||
|
||||
function ensureBenchDir (benchDir, output) {
|
||||
if (!existsSync(benchDir)) {
|
||||
mkdirSync(benchDir)
|
||||
return
|
||||
}
|
||||
const stats = lstatSync(benchDir)
|
||||
if (stats.isSymbolicLink() || !stats.isDirectory()) {
|
||||
throw new Error(`Output path must be under .bench/: ${output}`)
|
||||
}
|
||||
}
|
||||
|
||||
function assertNoSymlinkAncestors (benchDir, outputParent, output) {
|
||||
const relativeParent = relative(benchDir, outputParent)
|
||||
if (relativeParent === '') return
|
||||
|
||||
let current = benchDir
|
||||
for (const segment of relativeParent.split(sep)) {
|
||||
current = resolve(current, segment)
|
||||
assertNotSymlink(current, output)
|
||||
}
|
||||
}
|
||||
|
||||
function assertNotSymlink (path, output) {
|
||||
if (existsSync(path) && lstatSync(path).isSymbolicLink()) {
|
||||
throw new Error(`Output path must be under .bench/: ${output}`)
|
||||
}
|
||||
}
|
||||
|
||||
function nearestExistingAncestor (path) {
|
||||
let current = path
|
||||
while (!existsSync(current)) {
|
||||
const parent = dirname(current)
|
||||
if (parent === current) return current
|
||||
current = parent
|
||||
}
|
||||
return current
|
||||
}
|
||||
|
||||
function isSameOrChild (base, target) {
|
||||
return !isOutside(relative(base, target))
|
||||
}
|
||||
|
||||
function isOutside (relativePath) {
|
||||
return relativePath === '..' || relativePath.startsWith(`..${sep}`) || isAbsolute(relativePath)
|
||||
}
|
||||
78
.github/scripts/bencher-result-from-pnpm-summary.mjs
vendored
Normal file
78
.github/scripts/bencher-result-from-pnpm-summary.mjs
vendored
Normal file
@@ -0,0 +1,78 @@
|
||||
#!/usr/bin/env node
|
||||
import { mkdir, readFile, writeFile } from 'node:fs/promises'
|
||||
import { dirname, resolve } from 'node:path'
|
||||
import { resolveBenchOutputPath } from './bench-output-path.mjs'
|
||||
|
||||
const { name, output, packageDir, summary } = parseArgs(process.argv.slice(2))
|
||||
const summaryPath = resolve(summary)
|
||||
const outputPath = resolveBenchOutputPath(output)
|
||||
const packagePath = resolve(packageDir)
|
||||
const { executionStatus } = JSON.parse(await readFile(summaryPath, 'utf8'))
|
||||
|
||||
const entry = executionStatus?.[packagePath]
|
||||
if (entry == null) {
|
||||
console.error(`No execution summary entry found for ${packagePath}`)
|
||||
process.exit(1)
|
||||
}
|
||||
if (entry.status !== 'passed') {
|
||||
console.error(`Execution summary entry for ${packagePath} has status ${entry.status}`)
|
||||
process.exit(1)
|
||||
}
|
||||
if (typeof entry.duration !== 'number') {
|
||||
console.error(`Execution summary entry for ${packagePath} does not include a duration`)
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
const durationSeconds = entry.duration / 1000
|
||||
|
||||
await mkdir(dirname(outputPath), { recursive: true })
|
||||
await writeFile(outputPath, JSON.stringify({
|
||||
results: [
|
||||
{
|
||||
command: name,
|
||||
mean: durationSeconds,
|
||||
stddev: 0,
|
||||
median: durationSeconds,
|
||||
user: 0,
|
||||
system: 0,
|
||||
min: durationSeconds,
|
||||
max: durationSeconds,
|
||||
times: [durationSeconds],
|
||||
exit_codes: [0],
|
||||
},
|
||||
],
|
||||
}, null, 2) + '\n')
|
||||
|
||||
function parseArgs (args) {
|
||||
let name
|
||||
let output
|
||||
let packageDir
|
||||
let summary = 'pnpm-exec-summary.json'
|
||||
|
||||
for (let i = 0; i < args.length; i++) {
|
||||
const arg = args[i]
|
||||
if (arg === '--name') {
|
||||
name = args[++i]
|
||||
} else if (arg === '--output') {
|
||||
output = args[++i]
|
||||
} else if (arg === '--package-dir') {
|
||||
packageDir = args[++i]
|
||||
} else if (arg === '--summary') {
|
||||
summary = args[++i]
|
||||
} else {
|
||||
usage(`unknown argument: ${arg}`)
|
||||
}
|
||||
}
|
||||
|
||||
if (!name) usage('missing --name')
|
||||
if (!output) usage('missing --output')
|
||||
if (!packageDir) usage('missing --package-dir')
|
||||
|
||||
return { name, output, packageDir, summary }
|
||||
}
|
||||
|
||||
function usage (message) {
|
||||
console.error(message)
|
||||
console.error('Usage: bencher-result-from-pnpm-summary.mjs --name <benchmark> --output <file> --package-dir <dir> [--summary <file>]')
|
||||
process.exit(1)
|
||||
}
|
||||
98
.github/scripts/measure-command.mjs
vendored
Normal file
98
.github/scripts/measure-command.mjs
vendored
Normal file
@@ -0,0 +1,98 @@
|
||||
#!/usr/bin/env node
|
||||
import { mkdir, writeFile } from 'node:fs/promises'
|
||||
import { dirname } from 'node:path'
|
||||
import { spawn } from 'node:child_process'
|
||||
import { performance } from 'node:perf_hooks'
|
||||
import { resolveBenchOutputPath } from './bench-output-path.mjs'
|
||||
|
||||
const { name, output, command } = parseArgs(process.argv.slice(2))
|
||||
const outputPath = resolveBenchOutputPath(output)
|
||||
|
||||
await mkdir(dirname(outputPath), { recursive: true })
|
||||
|
||||
const startedAt = performance.now()
|
||||
const exitCode = await runCommand(command)
|
||||
const durationSeconds = (performance.now() - startedAt) / 1000
|
||||
|
||||
await writeFile(outputPath, JSON.stringify({
|
||||
results: [
|
||||
{
|
||||
command: name,
|
||||
mean: durationSeconds,
|
||||
stddev: 0,
|
||||
median: durationSeconds,
|
||||
user: 0,
|
||||
system: 0,
|
||||
min: durationSeconds,
|
||||
max: durationSeconds,
|
||||
times: [durationSeconds],
|
||||
exit_codes: [exitCode],
|
||||
},
|
||||
],
|
||||
}, null, 2) + '\n')
|
||||
|
||||
process.exitCode = exitCode
|
||||
|
||||
function parseArgs (args) {
|
||||
let name
|
||||
let output
|
||||
const commandIndex = args.indexOf('--')
|
||||
|
||||
if (commandIndex === -1) {
|
||||
usage('missing command separator: --')
|
||||
}
|
||||
|
||||
for (let i = 0; i < commandIndex; i++) {
|
||||
const arg = args[i]
|
||||
if (arg === '--name') {
|
||||
name = args[++i]
|
||||
} else if (arg === '--output') {
|
||||
output = args[++i]
|
||||
} else {
|
||||
usage(`unknown argument: ${arg}`)
|
||||
}
|
||||
}
|
||||
|
||||
const command = args.slice(commandIndex + 1)
|
||||
if (!name) usage('missing --name')
|
||||
if (!output) usage('missing --output')
|
||||
if (command.length === 0) usage('missing command')
|
||||
|
||||
return { name, output, command }
|
||||
}
|
||||
|
||||
function usage (message) {
|
||||
console.error(message)
|
||||
console.error('Usage: measure-command.mjs --name <benchmark> --output <file> -- <command> [args...]')
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
function runCommand ([command, ...args]) {
|
||||
return new Promise((resolve) => {
|
||||
const shell = process.platform === 'win32'
|
||||
if (shell) {
|
||||
validateWindowsShellArgs([command, ...args])
|
||||
}
|
||||
const child = spawn(command, args, { shell, stdio: 'inherit' })
|
||||
child.on('error', (err) => {
|
||||
console.error(err)
|
||||
resolve(1)
|
||||
})
|
||||
child.on('close', (code, signal) => {
|
||||
if (signal) {
|
||||
console.error(`Command terminated by signal ${signal}`)
|
||||
resolve(1)
|
||||
} else {
|
||||
resolve(code ?? 1)
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
function validateWindowsShellArgs (args) {
|
||||
for (const arg of args) {
|
||||
if (/[&|<>^%\r\n]/.test(arg)) {
|
||||
throw new Error(`Cannot run command with Windows shell metacharacters: ${arg}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
53
.github/scripts/merge-bencher-results.mjs
vendored
Normal file
53
.github/scripts/merge-bencher-results.mjs
vendored
Normal file
@@ -0,0 +1,53 @@
|
||||
#!/usr/bin/env node
|
||||
import { mkdir, readFile, writeFile } from 'node:fs/promises'
|
||||
import { dirname } from 'node:path'
|
||||
import { resolveBenchOutputPath } from './bench-output-path.mjs'
|
||||
|
||||
const { output, files } = parseArgs(process.argv.slice(2))
|
||||
const outputPath = resolveBenchOutputPath(output)
|
||||
const results = []
|
||||
|
||||
for (const file of files) {
|
||||
const report = JSON.parse(await readFile(file, 'utf8'))
|
||||
if (Array.isArray(report.results)) {
|
||||
results.push(...report.results)
|
||||
}
|
||||
}
|
||||
|
||||
if (results.length === 0) {
|
||||
console.error('No Bencher results found to merge')
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
await mkdir(dirname(outputPath), { recursive: true })
|
||||
await writeFile(outputPath, JSON.stringify({ results }, null, 2) + '\n')
|
||||
|
||||
function parseArgs (args) {
|
||||
let output
|
||||
const filesIndex = args.indexOf('--')
|
||||
|
||||
if (filesIndex === -1) {
|
||||
usage('missing file separator: --')
|
||||
}
|
||||
|
||||
for (let i = 0; i < filesIndex; i++) {
|
||||
const arg = args[i]
|
||||
if (arg === '--output') {
|
||||
output = args[++i]
|
||||
} else {
|
||||
usage(`unknown argument: ${arg}`)
|
||||
}
|
||||
}
|
||||
|
||||
const files = args.slice(filesIndex + 1)
|
||||
if (!output) usage('missing --output')
|
||||
if (files.length === 0) usage('missing files')
|
||||
|
||||
return { output, files }
|
||||
}
|
||||
|
||||
function usage (message) {
|
||||
console.error(message)
|
||||
console.error('Usage: merge-bencher-results.mjs --output <file> -- <file> [file...]')
|
||||
process.exit(1)
|
||||
}
|
||||
Reference in New Issue
Block a user