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:
Zoltan Kochan
2026-06-14 18:44:17 +02:00
committed by GitHub
parent 98745562b0
commit 9ddc86b635
10 changed files with 663 additions and 11 deletions

67
.github/scripts/bench-output-path.mjs vendored Normal file
View 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)
}

View 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
View 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}`)
}
}
}

View 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)
}