feat(dev-infra): make gate plans inspectable and replayable
This commit is contained in:
220
scripts/gate-log-helper.mjs
Normal file
220
scripts/gate-log-helper.mjs
Normal file
@@ -0,0 +1,220 @@
|
||||
#!/usr/bin/env node
|
||||
/** Pin the repository and each log-path component before creating or operating on private logs. */
|
||||
|
||||
import { constants } from 'node:fs'
|
||||
import { chmod, lstat, mkdir, open, readdir, stat, unlink } from 'node:fs/promises'
|
||||
import { isAbsolute, sep } from 'node:path'
|
||||
|
||||
const MAX_REQUEST_BYTES = 8 * 1024 * 1024
|
||||
const LOG_NAME = /^[a-zA-Z0-9][a-zA-Z0-9.-]*\.log$/
|
||||
|
||||
function errorCode(error) {
|
||||
return typeof error === 'object' && error !== null && 'code' in error
|
||||
? error.code
|
||||
: undefined
|
||||
}
|
||||
|
||||
async function readRequest() {
|
||||
const chunks = []
|
||||
let bytes = 0
|
||||
for await (const chunk of process.stdin) {
|
||||
bytes += chunk.length
|
||||
if (bytes > MAX_REQUEST_BYTES) throw new Error('request exceeds the gate-log helper limit')
|
||||
chunks.push(chunk)
|
||||
}
|
||||
return JSON.parse(Buffer.concat(chunks).toString('utf8'))
|
||||
}
|
||||
|
||||
function assertInteger(value, label, minimum) {
|
||||
if (!Number.isSafeInteger(value) || value < minimum) {
|
||||
throw new Error(`${label} must be an integer of at least ${minimum}`)
|
||||
}
|
||||
}
|
||||
|
||||
function assertLogName(name) {
|
||||
if (typeof name !== 'string' || !LOG_NAME.test(name)) {
|
||||
throw new Error(`invalid gate-log filename ${JSON.stringify(name)}`)
|
||||
}
|
||||
}
|
||||
|
||||
function assertRequest(request) {
|
||||
if (typeof request !== 'object' || request === null) throw new Error('gate-log request must be an object')
|
||||
switch (request.operation) {
|
||||
case 'write':
|
||||
assertLogName(request.filename)
|
||||
assertInteger(request.retention, 'retention', 1)
|
||||
if (typeof request.content !== 'string') throw new Error('gate-log content must be a string')
|
||||
return
|
||||
case 'prune':
|
||||
assertInteger(request.retain, 'retain', 0)
|
||||
return
|
||||
case 'clean':
|
||||
return
|
||||
default:
|
||||
throw new Error(`unsupported gate-log operation ${JSON.stringify(request.operation)}`)
|
||||
}
|
||||
}
|
||||
|
||||
function assertIdentity(value, label) {
|
||||
if (
|
||||
typeof value !== 'object'
|
||||
|| value === null
|
||||
|| typeof value.dev !== 'string'
|
||||
|| typeof value.ino !== 'string'
|
||||
) {
|
||||
throw new Error(`missing expected ${label} identity`)
|
||||
}
|
||||
}
|
||||
|
||||
function identityOf(metadata) {
|
||||
return { dev: String(metadata.dev), ino: String(metadata.ino) }
|
||||
}
|
||||
|
||||
function sameIdentity(metadata, expected) {
|
||||
return String(metadata.dev) === expected.dev && String(metadata.ino) === expected.ino
|
||||
}
|
||||
|
||||
async function assertPinnedRepository(repository) {
|
||||
if (
|
||||
typeof repository !== 'object'
|
||||
|| repository === null
|
||||
|| typeof repository.root !== 'string'
|
||||
|| !isAbsolute(repository.root)
|
||||
|| typeof repository.relative !== 'string'
|
||||
|| repository.relative === ''
|
||||
|| repository.relative === '..'
|
||||
|| repository.relative.startsWith(`..${sep}`)
|
||||
|| isAbsolute(repository.relative)
|
||||
) {
|
||||
throw new Error('invalid repository-relative gate-log path')
|
||||
}
|
||||
assertIdentity(repository.identity, 'repository')
|
||||
const pinnedMetadata = await stat('.', { bigint: true })
|
||||
if (!pinnedMetadata.isDirectory() || !sameIdentity(pinnedMetadata, repository.identity)) {
|
||||
throw new Error('gate-log repository identity changed before the helper started')
|
||||
}
|
||||
const rootMetadata = await lstat(repository.root, { bigint: true })
|
||||
if (
|
||||
!rootMetadata.isDirectory()
|
||||
|| rootMetadata.isSymbolicLink()
|
||||
|| !sameIdentity(rootMetadata, repository.identity)
|
||||
) {
|
||||
throw new Error('gate-log repository root is not a real directory')
|
||||
}
|
||||
}
|
||||
|
||||
async function enterLogDirectory(relativePath, create) {
|
||||
const traversed = []
|
||||
for (const component of relativePath.split(sep)) {
|
||||
if (component === '' || component === '.' || component === '..') {
|
||||
throw new Error(`invalid gate-log path component ${JSON.stringify(component)}`)
|
||||
}
|
||||
traversed.push(component)
|
||||
let componentMetadata
|
||||
try {
|
||||
componentMetadata = await lstat(component, { bigint: true })
|
||||
} catch (error) {
|
||||
if (errorCode(error) !== 'ENOENT') throw error
|
||||
if (!create) return undefined
|
||||
try {
|
||||
await mkdir(component, { mode: 0o700 })
|
||||
} catch (mkdirError) {
|
||||
if (errorCode(mkdirError) !== 'EEXIST') throw mkdirError
|
||||
}
|
||||
componentMetadata = await lstat(component, { bigint: true })
|
||||
}
|
||||
const shown = traversed.join('/')
|
||||
if (!componentMetadata.isDirectory() || componentMetadata.isSymbolicLink()) {
|
||||
throw new Error(`gate-log path component is not a real directory: ${shown}`)
|
||||
}
|
||||
const expected = identityOf(componentMetadata)
|
||||
process.chdir(component)
|
||||
const pinnedMetadata = await stat('.', { bigint: true })
|
||||
if (!pinnedMetadata.isDirectory() || !sameIdentity(pinnedMetadata, expected)) {
|
||||
throw new Error(`gate-log path component identity changed before pinning: ${shown}`)
|
||||
}
|
||||
}
|
||||
await chmod('.', 0o700)
|
||||
return identityOf(await stat('.', { bigint: true }))
|
||||
}
|
||||
|
||||
async function removeOldLogs(retain, newest) {
|
||||
assertInteger(retain, 'retain', 0)
|
||||
const entries = await readdir('.', { withFileTypes: true })
|
||||
const logs = []
|
||||
for (const entry of entries) {
|
||||
if (!entry.isFile() || !LOG_NAME.test(entry.name)) continue
|
||||
let metadata
|
||||
try {
|
||||
metadata = await lstat(entry.name, { bigint: true })
|
||||
} catch (error) {
|
||||
if (errorCode(error) === 'ENOENT') continue
|
||||
throw error
|
||||
}
|
||||
if (!metadata.isFile() || metadata.isSymbolicLink()) continue
|
||||
logs.push({ name: entry.name, mtimeNs: metadata.mtimeNs })
|
||||
}
|
||||
logs.sort((left, right) => {
|
||||
if (left.name === newest) return 1
|
||||
if (right.name === newest) return -1
|
||||
if (left.mtimeNs < right.mtimeNs) return -1
|
||||
if (left.mtimeNs > right.mtimeNs) return 1
|
||||
return left.name.localeCompare(right.name)
|
||||
})
|
||||
const removed = []
|
||||
for (const entry of logs.slice(0, Math.max(0, logs.length - retain))) {
|
||||
try {
|
||||
await unlink(entry.name)
|
||||
removed.push(entry.name)
|
||||
} catch (error) {
|
||||
if (errorCode(error) !== 'ENOENT') throw error
|
||||
}
|
||||
}
|
||||
return removed
|
||||
}
|
||||
|
||||
async function writeLog(request) {
|
||||
assertLogName(request.filename)
|
||||
assertInteger(request.retention, 'retention', 1)
|
||||
if (typeof request.content !== 'string') throw new Error('gate-log content must be a string')
|
||||
const handle = await open(
|
||||
request.filename,
|
||||
constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | constants.O_NOFOLLOW,
|
||||
0o600,
|
||||
)
|
||||
try {
|
||||
await handle.writeFile(request.content, 'utf8')
|
||||
await handle.chmod(0o600)
|
||||
} finally {
|
||||
await handle.close()
|
||||
}
|
||||
const removed = await removeOldLogs(request.retention, request.filename)
|
||||
return { filename: request.filename, removed }
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const request = await readRequest()
|
||||
assertRequest(request)
|
||||
await assertPinnedRepository(request.repository)
|
||||
const directory = await enterLogDirectory(request.repository.relative, request.operation === 'write')
|
||||
if (directory === undefined) return { removed: [] }
|
||||
switch (request.operation) {
|
||||
case 'write': {
|
||||
const result = await writeLog(request)
|
||||
return { ...result, directory }
|
||||
}
|
||||
case 'prune':
|
||||
return { directory, removed: await removeOldLogs(request.retain) }
|
||||
case 'clean':
|
||||
return { directory, removed: await removeOldLogs(0) }
|
||||
default:
|
||||
throw new Error(`unsupported gate-log operation ${JSON.stringify(request.operation)}`)
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
process.stdout.write(`${JSON.stringify(await main())}\n`)
|
||||
} catch (error) {
|
||||
process.stderr.write(`gate-log-helper: ${error instanceof Error ? error.message : String(error)}\n`)
|
||||
process.exitCode = 1
|
||||
}
|
||||
@@ -58,4 +58,10 @@ describe('publint package runner', () => {
|
||||
expect(result.status).toBe(1)
|
||||
expect(result.stdout).toContain('unpublished.js')
|
||||
})
|
||||
|
||||
it('rejects a public export whose built file is missing', () => {
|
||||
const result = run(fixture('./lib/missing.js'))
|
||||
expect(result.status).toBe(1)
|
||||
expect(result.stdout).toContain('missing.js')
|
||||
})
|
||||
})
|
||||
|
||||
523
scripts/run-gates.spec.ts
Normal file
523
scripts/run-gates.spec.ts
Normal file
@@ -0,0 +1,523 @@
|
||||
import {
|
||||
existsSync,
|
||||
mkdirSync,
|
||||
mkdtempSync,
|
||||
readFileSync,
|
||||
readdirSync,
|
||||
renameSync,
|
||||
rmSync,
|
||||
statSync,
|
||||
symlinkSync,
|
||||
writeFileSync,
|
||||
} from 'node:fs'
|
||||
import { spawnSync } from 'node:child_process'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import {
|
||||
cleanGateFailureLogs,
|
||||
executeGatePlan,
|
||||
failureLogUnavailableReason,
|
||||
formatGateFailureLog,
|
||||
formatGatePlanJson,
|
||||
formatGatePlanList,
|
||||
formatGateResultReason,
|
||||
formatOnlyNotice,
|
||||
gateDependencyClosure,
|
||||
gatePlanForMode,
|
||||
listedGatePlan,
|
||||
limitGateFailureLog,
|
||||
parseCliRequest,
|
||||
pruneGateLogs,
|
||||
replayCommand,
|
||||
resolveGateEnvironment,
|
||||
resolvePlanConcurrency,
|
||||
runGate,
|
||||
validateGatePlan,
|
||||
writeGateFailureLog,
|
||||
type Gate,
|
||||
type GatePlan,
|
||||
type GateResult,
|
||||
} from './run-gates.ts'
|
||||
|
||||
const temporaryRoots: string[] = []
|
||||
const repositoryRoot = join(import.meta.dirname, '..')
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllEnvs()
|
||||
for (const root of temporaryRoots.splice(0)) rmSync(root, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
function gate(id: string, options: Partial<Gate> = {}): Gate {
|
||||
return {
|
||||
id,
|
||||
label: id,
|
||||
displayCommand: `run ${id}`,
|
||||
command: process.execPath,
|
||||
args: ['-e', ''],
|
||||
...options,
|
||||
}
|
||||
}
|
||||
|
||||
function plan(gates: Gate[]): GatePlan {
|
||||
return { mode: 'check-all', script: 'check:all', gates }
|
||||
}
|
||||
|
||||
function resultFor(subject: Gate, status: GateResult['status'] = 'passed'): GateResult {
|
||||
return {
|
||||
gate: subject,
|
||||
status,
|
||||
durationMs: 10,
|
||||
stdout: '',
|
||||
stderr: '',
|
||||
output: [],
|
||||
exitCode: status === 'passed' ? 0 : 1,
|
||||
signalCode: null,
|
||||
}
|
||||
}
|
||||
|
||||
function temporaryRoot(prefix = 'dsh-gate-logs-'): string {
|
||||
const root = mkdtempSync(join(tmpdir(), prefix))
|
||||
temporaryRoots.push(root)
|
||||
return root
|
||||
}
|
||||
|
||||
function withPnpmEntrypoint<T>(action: () => T): T {
|
||||
const previous = process.env.npm_execpath
|
||||
process.env.npm_execpath = '/private/pnpm.cjs'
|
||||
try {
|
||||
return action()
|
||||
} finally {
|
||||
if (previous === undefined) Reflect.deleteProperty(process.env, 'npm_execpath')
|
||||
else process.env.npm_execpath = previous
|
||||
}
|
||||
}
|
||||
|
||||
describe('gate plan validation', () => {
|
||||
it.each([
|
||||
'ci-primary',
|
||||
'ci-static',
|
||||
'ci-lint',
|
||||
'ci-coverage',
|
||||
'ci-snapshot',
|
||||
'ci-artifacts',
|
||||
'ci-consumers',
|
||||
'ci-windows-blocking',
|
||||
'ci-windows-complete',
|
||||
'ci-windows-observational',
|
||||
'node-compat',
|
||||
'check-all',
|
||||
'doc-sync',
|
||||
] as const)('constructs a valid non-empty %s plan', (mode) => {
|
||||
const subject = withPnpmEntrypoint(() => gatePlanForMode(mode))
|
||||
expect(() => {
|
||||
validateGatePlan(subject)
|
||||
}).not.toThrow()
|
||||
})
|
||||
|
||||
it.each([
|
||||
['empty', plan([]), /plan has no gates/],
|
||||
['duplicate ids', plan([gate('same'), gate('same')]), /duplicate gate id "same"/],
|
||||
['unknown dependencies', plan([gate('subject', { needs: ['missing'] })]), /depends on unknown gate "missing"/],
|
||||
['cycles', plan([gate('first', { needs: ['second'] }), gate('second', { needs: ['first'] })]), /dependency cycle: first -> second -> first/],
|
||||
])('rejects %s before starting a child', async (_label, invalid, message) => {
|
||||
const execute = vi.fn(async (subject: Gate) => resultFor(subject))
|
||||
await expect(executeGatePlan(invalid, 1, execute)).rejects.toThrow(message)
|
||||
expect(execute).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('rejects an invalid plan worker bound', () => {
|
||||
expect(() => {
|
||||
validateGatePlan({ ...plan([gate('subject')]), maxWorkers: 0 })
|
||||
}).toThrow(
|
||||
'maxWorkers must be a positive integer',
|
||||
)
|
||||
})
|
||||
|
||||
it('rejects an executor request above the plan worker ceiling before starting a child', async () => {
|
||||
const execute = vi.fn(async (subject: Gate) => resultFor(subject))
|
||||
await expect(executeGatePlan({ ...plan([gate('subject')]), maxWorkers: 1 }, 2, execute)).rejects.toThrow(
|
||||
'exceeds the check-all plan ceiling 1',
|
||||
)
|
||||
expect(execute).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('selects a target with its transitive dependencies in canonical plan order', () => {
|
||||
const subject = plan([
|
||||
gate('prepare'),
|
||||
gate('build', { needs: ['prepare'] }),
|
||||
gate('snapshot', { needs: ['build'], env: { DSH_EXAMPLE_MODE: { operation: 'set', value: 'lib' } } }),
|
||||
gate('unrelated'),
|
||||
])
|
||||
expect(gateDependencyClosure(subject, 'snapshot').map(item => item.id)).toEqual(['prepare', 'build', 'snapshot'])
|
||||
expect(gateDependencyClosure(subject, 'snapshot').at(-1)?.env).toEqual({
|
||||
DSH_EXAMPLE_MODE: { operation: 'set', value: 'lib' },
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('gate plan inspection and replay', () => {
|
||||
it('parses package-script separators, list JSON, focused runs, and cleanup', () => {
|
||||
expect(parseCliRequest(['check-all', '--', '--list', '--json'])).toEqual({
|
||||
kind: 'run', mode: 'check-all', list: true, json: true,
|
||||
})
|
||||
expect(parseCliRequest(['check-all', '--only', 'snapshot'])).toEqual({
|
||||
kind: 'run', mode: 'check-all', list: false, json: false, only: 'snapshot',
|
||||
})
|
||||
expect(parseCliRequest(['--clean-logs'])).toEqual({ kind: 'clean-logs' })
|
||||
expect(() => parseCliRequest(['check-all', '--json'])).toThrow('--json requires --list')
|
||||
expect(() => parseCliRequest(['pre-push'])).toThrow('expected mode')
|
||||
})
|
||||
|
||||
it('renders deterministic human and stable JSON fields without inherited environment values', () => {
|
||||
vi.stubEnv('DEEPSEEK_API_KEY', 'ambient-secret')
|
||||
const subject = plan([
|
||||
gate('prepare'),
|
||||
gate('subject', {
|
||||
needs: ['prepare'],
|
||||
allowFailure: true,
|
||||
env: {
|
||||
Z_MODE: { operation: 'set', value: 'lib' },
|
||||
ACCESS_TOKEN: { operation: 'set', value: 'scheduler-secret' },
|
||||
NODE_OPTIONS: { operation: 'append', value: '--max-old-space-size=8192' },
|
||||
},
|
||||
}),
|
||||
])
|
||||
|
||||
const json = formatGatePlanJson(subject)
|
||||
expect(formatGatePlanJson(subject)).toBe(json)
|
||||
expect(json).not.toContain('ambient-secret')
|
||||
expect(json).not.toContain('scheduler-secret')
|
||||
expect(JSON.parse(json)).toEqual({
|
||||
version: 1,
|
||||
mode: 'check-all',
|
||||
script: 'check:all',
|
||||
scope: 'complete',
|
||||
maxWorkers: null,
|
||||
gates: [
|
||||
{ id: 'prepare', label: 'prepare', command: 'run prepare', needs: [], env: {}, blocking: true },
|
||||
{
|
||||
id: 'subject',
|
||||
label: 'subject',
|
||||
command: 'run subject',
|
||||
needs: ['prepare'],
|
||||
env: {
|
||||
ACCESS_TOKEN: { operation: 'set', value: '<redacted>' },
|
||||
NODE_OPTIONS: { operation: 'append', value: '--max-old-space-size=8192' },
|
||||
Z_MODE: { operation: 'set', value: 'lib' },
|
||||
},
|
||||
blocking: false,
|
||||
},
|
||||
],
|
||||
})
|
||||
expect(formatGatePlanList(subject)).toContain('- subject [non-blocking] subject')
|
||||
expect(formatGatePlanList(subject)).toContain('needs: prepare')
|
||||
expect(formatGatePlanList(subject)).toContain('max workers: (host and gate count)')
|
||||
})
|
||||
|
||||
it('emits one clean JSON object through the documented silent package-script entry', () => {
|
||||
const result = spawnSync('pnpm', [
|
||||
'--silent',
|
||||
'run',
|
||||
'check:ci:consumers',
|
||||
'--',
|
||||
'--list',
|
||||
'--json',
|
||||
], {
|
||||
cwd: repositoryRoot,
|
||||
encoding: 'utf8',
|
||||
shell: process.platform === 'win32',
|
||||
timeout: 10_000,
|
||||
})
|
||||
if (result.error !== undefined) throw result.error
|
||||
expect(result.status, result.stderr).toBe(0)
|
||||
expect(JSON.parse(result.stdout)).toMatchObject({
|
||||
version: 1,
|
||||
mode: 'ci-consumers',
|
||||
script: 'check:ci:consumers',
|
||||
scope: 'complete',
|
||||
maxWorkers: 7,
|
||||
})
|
||||
})
|
||||
|
||||
it('renders a cross-platform scheduler replay and labels focused evidence', () => {
|
||||
const subject = plan([gate('snapshot')])
|
||||
expect(replayCommand(subject, 'snapshot')).toBe('pnpm run check:all -- --only snapshot')
|
||||
expect(formatOnlyNotice(subject, 'snapshot')).toBe(
|
||||
'run-gates: --only snapshot is partial diagnostic evidence; the complete owning mode is pnpm run check:all.',
|
||||
)
|
||||
})
|
||||
|
||||
it('resolves append, set, and unset operations only when spawning', () => {
|
||||
const resolved = resolveGateEnvironment(gate('subject', {
|
||||
env: {
|
||||
NODE_OPTIONS: { operation: 'append', value: '--max-old-space-size=8192' },
|
||||
MODE: { operation: 'set', value: 'lib' },
|
||||
REMOVE_ME: { operation: 'unset' },
|
||||
},
|
||||
}), { NODE_OPTIONS: '--trace-warnings', REMOVE_ME: 'yes', INHERITED: 'kept' })
|
||||
expect(resolved).toEqual({
|
||||
NODE_OPTIONS: '--trace-warnings --max-old-space-size=8192',
|
||||
MODE: 'lib',
|
||||
INHERITED: 'kept',
|
||||
})
|
||||
})
|
||||
|
||||
it.skipIf(process.platform === 'win32')('reports signal termination as an orthogonal real-process outcome', async () => {
|
||||
const subjectGate = gate('terminated', {
|
||||
args: ['-e', "process.kill(process.pid, 'SIGTERM')"],
|
||||
})
|
||||
const result = await runGate(subjectGate)
|
||||
|
||||
expect(result.status).toBe('failed')
|
||||
expect(result.exitCode).toBeNull()
|
||||
expect(result.signalCode).toBe('SIGTERM')
|
||||
expect(formatGateResultReason(result)).toBe('signal SIGTERM')
|
||||
expect(formatGateFailureLog(plan([subjectGate]), result)).toContain('signal: SIGTERM')
|
||||
})
|
||||
})
|
||||
|
||||
describe('gate failure logs', () => {
|
||||
it('records attributable scheduler metadata without inherited secrets', () => {
|
||||
vi.stubEnv('DEEPSEEK_API_KEY', 'ambient-secret')
|
||||
const subjectGate = gate('snapshot', {
|
||||
env: {
|
||||
DSH_EXAMPLE_MODE: { operation: 'set', value: 'lib' },
|
||||
ACCESS_TOKEN: { operation: 'set', value: 'scheduler-secret' },
|
||||
},
|
||||
})
|
||||
const subject = plan([subjectGate])
|
||||
const failure: GateResult = {
|
||||
...resultFor(subjectGate, 'failed'),
|
||||
output: [{ stream: 'stderr', text: 'failure details\n' }],
|
||||
stderr: 'failure details\n',
|
||||
}
|
||||
const log = formatGateFailureLog(subject, failure)
|
||||
expect(log).toContain('replay: pnpm run check:all -- --only snapshot')
|
||||
expect(log).toContain('DSH_EXAMPLE_MODE')
|
||||
expect(log).toContain('<redacted>')
|
||||
expect(log).toContain('[stderr]\nfailure details')
|
||||
expect(log).not.toContain('ambient-secret')
|
||||
expect(log).not.toContain('scheduler-secret')
|
||||
})
|
||||
|
||||
it.skipIf(process.platform === 'win32')('uses private exclusive files and bounds retention', async () => {
|
||||
const repositoryRoot = temporaryRoot()
|
||||
const directory = join(repositoryRoot, '.cache/gates')
|
||||
const subjectGate = gate('subject')
|
||||
const subject = plan([subjectGate])
|
||||
const failure = resultFor(subjectGate, 'failed')
|
||||
|
||||
const first = await writeGateFailureLog(subject, failure, {
|
||||
directory, repositoryRoot, retention: 2, unique: 'first', now: new Date('2026-07-27T00:00:00Z'), platform: 'linux',
|
||||
})
|
||||
const second = await writeGateFailureLog(subject, failure, {
|
||||
directory, repositoryRoot, retention: 2, unique: 'second', now: new Date('2026-07-27T00:00:01Z'), platform: 'linux',
|
||||
})
|
||||
const third = await writeGateFailureLog(subject, failure, {
|
||||
directory, repositoryRoot, retention: 2, unique: 'third', now: new Date('2026-07-27T00:00:02Z'), platform: 'linux',
|
||||
})
|
||||
|
||||
expect(readdirSync(directory).sort()).toEqual([second, third].map(path => path.slice(directory.length + 1)).sort())
|
||||
expect(readFileSync(third, 'utf8')).toContain('run-gates failure log')
|
||||
expect(statSync(directory).mode & 0o777).toBe(0o700)
|
||||
expect(statSync(third).mode & 0o777).toBe(0o600)
|
||||
expect(() => statSync(first)).toThrow()
|
||||
await expect(writeGateFailureLog(subject, failure, {
|
||||
directory, repositoryRoot, retention: 3, unique: 'third', now: new Date('2026-07-27T00:00:02Z'), platform: 'linux',
|
||||
})).rejects.toThrow('EEXIST')
|
||||
await cleanGateFailureLogs(directory, repositoryRoot)
|
||||
expect(readdirSync(directory)).toEqual([])
|
||||
})
|
||||
|
||||
it.skipIf(process.platform === 'win32')('uses cross-platform filenames for replay-safe gate ids', async () => {
|
||||
const repositoryRoot = temporaryRoot()
|
||||
const directory = join(repositoryRoot, '.cache/gates')
|
||||
const subjectGate = gate('build:web')
|
||||
const path = await writeGateFailureLog(
|
||||
plan([subjectGate]),
|
||||
resultFor(subjectGate, 'failed'),
|
||||
{
|
||||
directory, repositoryRoot, retention: 1, unique: 'unique', now: new Date('2026-07-27T00:00:00Z'), platform: 'linux',
|
||||
},
|
||||
)
|
||||
expect(path.slice(directory.length + 1)).toContain('-build-web-')
|
||||
expect(path.slice(directory.length + 1)).not.toContain(':')
|
||||
})
|
||||
|
||||
it.skipIf(process.platform === 'win32')('bounds retained UTF-8 output with explicit truncation metadata', async () => {
|
||||
const repositoryRoot = temporaryRoot()
|
||||
const directory = join(repositoryRoot, '.cache/gates')
|
||||
const subjectGate = gate('subject')
|
||||
const failure: GateResult = {
|
||||
...resultFor(subjectGate, 'failed'),
|
||||
output: [{ stream: 'stderr', text: `${'界'.repeat(200)}\nlast detail\n` }],
|
||||
}
|
||||
const path = await writeGateFailureLog(plan([subjectGate]), failure, {
|
||||
directory,
|
||||
repositoryRoot,
|
||||
retention: 1,
|
||||
maxBytes: 256,
|
||||
unique: 'bounded',
|
||||
now: new Date('2026-07-27T00:00:00Z'),
|
||||
platform: 'linux',
|
||||
})
|
||||
const content = readFileSync(path, 'utf8')
|
||||
|
||||
expect(Buffer.byteLength(content)).toBeLessThanOrEqual(256)
|
||||
expect(content).toContain('[run-gates log truncated: original-bytes=')
|
||||
expect(content).toContain('max-bytes=256')
|
||||
expect(content).toContain('last detail')
|
||||
expect(content).not.toContain('\uFFFD')
|
||||
expect(limitGateFailureLog('x'.repeat(256), 256)).toBe('x'.repeat(256))
|
||||
})
|
||||
|
||||
it.skipIf(process.platform === 'win32')('accepts the worst-case JSON expansion of a bounded log', async () => {
|
||||
const repositoryRoot = temporaryRoot()
|
||||
const directory = join(repositoryRoot, '.cache/gates')
|
||||
const subjectGate = gate('subject')
|
||||
const failure: GateResult = {
|
||||
...resultFor(subjectGate, 'failed'),
|
||||
output: [{ stream: 'stderr', text: '\0'.repeat(400_000) }],
|
||||
}
|
||||
const path = await writeGateFailureLog(plan([subjectGate]), failure, {
|
||||
directory,
|
||||
repositoryRoot,
|
||||
retention: 1,
|
||||
maxBytes: 400_000,
|
||||
unique: 'control-heavy',
|
||||
platform: 'linux',
|
||||
})
|
||||
|
||||
expect(statSync(path).size).toBeLessThanOrEqual(400_000)
|
||||
expect(readFileSync(path, 'utf8')).not.toContain('\uFFFD')
|
||||
})
|
||||
|
||||
it('rejects symlinked repository cache components before writing, pruning, or cleanup', async () => {
|
||||
const auditRoot = temporaryRoot('dsh-gate-symlink-')
|
||||
const repositoryRoot = join(auditRoot, 'repository')
|
||||
const external = join(auditRoot, 'external')
|
||||
const directory = join(repositoryRoot, '.cache/gates')
|
||||
mkdirSync(repositoryRoot)
|
||||
mkdirSync(join(external, 'gates'), { recursive: true })
|
||||
const victim = join(external, 'gates/victim.log')
|
||||
writeFileSync(victim, 'keep\n')
|
||||
symlinkSync(external, join(repositoryRoot, '.cache'), process.platform === 'win32' ? 'junction' : 'dir')
|
||||
const subjectGate = gate('subject')
|
||||
const message = 'gate-log path component is a symbolic link: .cache'
|
||||
|
||||
await expect(writeGateFailureLog(plan([subjectGate]), resultFor(subjectGate, 'failed'), {
|
||||
directory, repositoryRoot, retention: 1, unique: 'safe', platform: 'linux',
|
||||
})).rejects.toThrow(message)
|
||||
await expect(pruneGateLogs(directory, 0, repositoryRoot)).rejects.toThrow(message)
|
||||
await expect(cleanGateFailureLogs(directory, repositoryRoot)).rejects.toThrow(message)
|
||||
expect(existsSync(victim)).toBe(true)
|
||||
})
|
||||
|
||||
it.skipIf(process.platform === 'win32')('pins write, prune, and cleanup before a concurrent ancestor swap', async () => {
|
||||
const subjectGate = gate('subject')
|
||||
const subject = plan([subjectGate])
|
||||
|
||||
for (const operation of ['write', 'prune', 'clean'] as const) {
|
||||
const auditRoot = temporaryRoot(`dsh-gate-${operation}-swap-`)
|
||||
const repositoryRoot = join(auditRoot, 'repository')
|
||||
const external = join(auditRoot, 'external')
|
||||
const cache = join(repositoryRoot, '.cache')
|
||||
const directory = join(cache, 'gates')
|
||||
const displacedCache = join(repositoryRoot, '.cache-pinned')
|
||||
mkdirSync(directory, { recursive: true })
|
||||
mkdirSync(external)
|
||||
writeFileSync(join(directory, 'old.log'), 'old private log\n')
|
||||
const victim = operation === 'write' ? undefined : join(external, 'gates/victim.log')
|
||||
if (victim !== undefined) {
|
||||
mkdirSync(join(external, 'gates'))
|
||||
writeFileSync(victim, 'keep\n')
|
||||
}
|
||||
const swapAncestor = (): void => {
|
||||
renameSync(cache, displacedCache)
|
||||
symlinkSync(external, cache, 'dir')
|
||||
}
|
||||
|
||||
let invocation: Promise<unknown>
|
||||
if (operation === 'write') {
|
||||
invocation = writeGateFailureLog(subject, resultFor(subjectGate, 'failed'), {
|
||||
directory,
|
||||
repositoryRoot,
|
||||
retention: 1,
|
||||
unique: operation,
|
||||
platform: 'linux',
|
||||
beforeHelper: swapAncestor,
|
||||
})
|
||||
} else if (operation === 'prune') {
|
||||
invocation = pruneGateLogs(directory, 0, repositoryRoot, swapAncestor)
|
||||
} else {
|
||||
invocation = cleanGateFailureLogs(directory, repositoryRoot, swapAncestor)
|
||||
}
|
||||
|
||||
await expect(invocation).rejects.toThrow('gate-log helper')
|
||||
if (victim === undefined) {
|
||||
expect(existsSync(join(external, 'gates'))).toBe(false)
|
||||
} else {
|
||||
expect(readFileSync(victim, 'utf8')).toBe('keep\n')
|
||||
expect(readdirSync(join(external, 'gates'))).toEqual(['victim.log'])
|
||||
}
|
||||
expect(readFileSync(join(displacedCache, 'gates/old.log'), 'utf8')).toBe('old private log\n')
|
||||
}
|
||||
})
|
||||
|
||||
it('uses a console-only fallback on Windows before creating a retention directory', async () => {
|
||||
const repositoryRoot = temporaryRoot()
|
||||
const directory = join(repositoryRoot, '.cache/gates')
|
||||
const subjectGate = gate('subject')
|
||||
expect(failureLogUnavailableReason('win32')).toContain('complete output remains on the console')
|
||||
expect(failureLogUnavailableReason('linux')).toBeUndefined()
|
||||
|
||||
await expect(writeGateFailureLog(plan([subjectGate]), resultFor(subjectGate, 'failed'), {
|
||||
directory, repositoryRoot, platform: 'win32',
|
||||
})).rejects.toThrow('retained failure logs are disabled on Windows')
|
||||
expect(existsSync(directory)).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Node 24 consumer plan', () => {
|
||||
it('owns the same seven-worker command pool and orders restored-artifact validation before dependent consumers', () => {
|
||||
const subject = withPnpmEntrypoint(() => gatePlanForMode('ci-consumers'))
|
||||
validateGatePlan(subject)
|
||||
expect(subject.maxWorkers).toBe(7)
|
||||
expect(listedGatePlan(subject).maxWorkers).toBe(7)
|
||||
expect(resolvePlanConcurrency(subject, undefined, 4)).toEqual({
|
||||
workers: 7,
|
||||
source: 'ci-consumers plan default 7',
|
||||
})
|
||||
expect(resolvePlanConcurrency(subject, '4', 32)).toEqual({
|
||||
workers: 4,
|
||||
source: '$DSH_GATE_CONCURRENCY',
|
||||
})
|
||||
expect(resolvePlanConcurrency(subject, '8', 32)).toEqual({
|
||||
workers: 7,
|
||||
source: '$DSH_GATE_CONCURRENCY, ci-consumers plan cap 7',
|
||||
})
|
||||
expect(subject.gates.map(item => item.id)).toEqual([
|
||||
'lint-and-duplication',
|
||||
'node-compat',
|
||||
'snapshot',
|
||||
'publint',
|
||||
'node-next-types',
|
||||
'built-package-invariants',
|
||||
'built-bin-smoke',
|
||||
])
|
||||
expect(subject.gates.find(item => item.id === 'publint')?.needs).toBeUndefined()
|
||||
expect(subject.gates.find(item => item.id === 'built-package-invariants')?.needs).toEqual(['publint'])
|
||||
for (const id of ['snapshot', 'node-next-types', 'built-bin-smoke']) {
|
||||
expect(subject.gates.find(item => item.id === id)?.needs).toEqual(['built-package-invariants'])
|
||||
}
|
||||
expect(gateDependencyClosure(subject, 'snapshot').map(item => item.id)).toEqual([
|
||||
'snapshot',
|
||||
'publint',
|
||||
'built-package-invariants',
|
||||
])
|
||||
expect(listedGatePlan(subject).gates.find(item => item.id === 'snapshot')?.env).toEqual({
|
||||
DSH_EXAMPLE_MODE: { operation: 'set', value: 'lib' },
|
||||
})
|
||||
})
|
||||
})
|
||||
1011
scripts/run-gates.ts
1011
scripts/run-gates.ts
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user