Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
144 changes: 144 additions & 0 deletions .github/scripts/assert-clean-vitest-log.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,144 @@
// Vitest's forks pool can respawn a worker mid-run when one fails to start or stops
// responding. The run's own summary and exit code do not see that respawn -- they only see
// whatever the respawned worker eventually reported -- so a file that logged one of these
// signatures can still be tallied as passed. A green summary and exit code 0 are therefore not
// sufficient release evidence on their own; this script reads the raw log text and fails
// closed when either signature appears, independent of what vitest itself reported.
//
// Deliberately narrow: this is not a general log-scanning framework. It knows exactly two
// signatures and does nothing else.
import { existsSync, readFileSync, statSync } from 'node:fs'
import { resolve } from 'node:path'
import { fileURLToPath } from 'node:url'

export const WORKER_FAILURE_SIGNATURES = [
'Failed to start forks worker',
'Timeout waiting for worker to respond',
]

function fail(message) {
throw new Error(message)
}

/**
* Scans a single log's text for every known worker-start failure signature.
* Returns one entry per signature that appears, each with its occurrence count and the
* matching lines (1-indexed), so a failure can be reported precisely.
*/
export function scanLogText(path, content) {
const lines = content.split(/\r?\n/)
const matches = []

for (const signature of WORKER_FAILURE_SIGNATURES) {
const matchingLines = []
lines.forEach((line, index) => {
if (line.includes(signature)) {
matchingLines.push({ lineNumber: index + 1, text: line })
}
})
if (matchingLines.length > 0) {
matches.push({ signature, count: matchingLines.length, lines: matchingLines })
}
}

return { path, matches }
}

/**
* Reads and scans every given log path. Fails closed: a missing or unreadable path is a
* thrown error, never a silently "clean" result.
*/
export function assertCleanVitestLogs(paths, io = {}) {
const readFile = io.readFile ?? ((path) => readFileSync(path, 'utf8'))
const exists = io.exists ?? existsSync
const stat = io.stat ?? statSync

if (!Array.isArray(paths) || paths.length === 0) {
fail('At least one log path is required')
}

const results = []

for (const path of paths) {
if (!exists(path)) {
fail(`Log file not found: ${path}`)
}

let isDirectory = false
try {
isDirectory = stat(path).isDirectory()
} catch {
// If stat itself fails, the read below will surface a precise error instead.
}
if (isDirectory) {
fail(`Log path is a directory, not a file: ${path}`)
}

let content
try {
content = readFile(path)
} catch (error) {
const reason = error instanceof Error ? error.message : String(error)
fail(`Unable to read log file ${path}: ${reason}`)
}

results.push(scanLogText(path, content))
}

const hasFailure = results.some((result) => result.matches.length > 0)
return { hasFailure, results }
}

export function formatReport({ hasFailure, results }) {
const lines = []

for (const result of results) {
if (result.matches.length === 0) {
lines.push(`clean: ${result.path}`)
continue
}
for (const match of result.matches) {
const occurrences = match.count === 1 ? 'occurrence' : 'occurrences'
lines.push(`SIGNATURE DETECTED in ${result.path}: "${match.signature}" (${match.count} ${occurrences})`)
for (const { lineNumber, text } of match.lines) {
lines.push(` ${result.path}:${lineNumber}: ${text}`)
}
}
}

lines.push(
hasFailure
? 'vitest log scan FAILED: absorbed worker-start failure signature(s) detected'
: 'vitest log scan passed: no absorbed worker-start failure signatures detected',
)

return lines.join('\n')
}

export function runCli(argv) {
if (argv.length === 0) {
fail('Usage: assert-clean-vitest-log.mjs <log-path> [log-path...]')
}

const outcome = assertCleanVitestLogs(argv)
const report = formatReport(outcome)

if (outcome.hasFailure) {
console.error(report)
process.exitCode = 1
return
}

console.log(report)
}

const isCli = process.argv[1] && fileURLToPath(import.meta.url) === resolve(process.argv[1])
if (isCli) {
try {
runCli(process.argv.slice(2))
} catch (error) {
const message = error instanceof Error ? error.message : String(error)
console.error(`assert-clean-vitest-log failed: ${message}`)
process.exitCode = 1
}
}
72 changes: 72 additions & 0 deletions .github/scripts/check-qualification-gate.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
import { existsSync, readFileSync } from 'node:fs'
import { resolve } from 'node:path'
import { fileURLToPath } from 'node:url'

function fail(message) {
throw new Error(message)
}

// Two independent signals decide the gate, not one, so an absent script can never stay
// optional forever:
// - contractPresent: does docs/qualification/ (the qualification contract) exist on the
// checked-out commit? It does not exist on `next` today; it lands with #681, in the same
// merge that adds the qualify:validate script.
// - scriptPresent: does package.json define a qualify:validate script?
//
// scriptPresent -> 'run': execute it for real; the caller must hard-fail on a non-zero exit.
// !scriptPresent && contractPresent -> 'missing': the contract has been declared mandatory but
// its validator is gone. This must hard-fail -- silently downgrading to a notice here is
// exactly the "permanently optional gate" this mechanism exists to prevent.
// !scriptPresent && !contractPresent -> 'notice': ordinary pre-#681 state; record and skip.
export function decideQualificationGate({ contractPresent, scriptPresent }) {
if (scriptPresent) {
return 'run'
}
if (contractPresent) {
return 'missing'
}
return 'notice'
}

function readScriptPresence(packageJsonPath) {
const pkg = JSON.parse(readFileSync(packageJsonPath, 'utf8'))
const scripts = pkg.scripts ?? {}
return Boolean(scripts['qualify:validate'])
}

function parseArguments(args) {
const options = { cwd: '.' }
const cwdIndex = args.indexOf('--cwd')
if (cwdIndex !== -1) {
const value = args[cwdIndex + 1]
if (!value || value.startsWith('--')) {
fail('--cwd requires a value')
}
options.cwd = value
}
return options
}

export function runCli(args) {
const { cwd } = parseArguments(args)
const contractPresent = existsSync(resolve(cwd, 'docs/qualification'))
const scriptPresent = readScriptPresence(resolve(cwd, 'package.json'))
const decision = decideQualificationGate({ contractPresent, scriptPresent })

if (decision === 'missing') {
fail('docs/qualification/ (the qualification contract) is present on this commit, but the qualify:validate script is missing from package.json. This gate is mandatory once the contract exists; restore the script before releasing.')
}

console.log(`decision=${decision}`)
}

const isCli = process.argv[1] && fileURLToPath(import.meta.url) === resolve(process.argv[1])
if (isCli) {
try {
runCli(process.argv.slice(2))
} catch (error) {
const message = error instanceof Error ? error.message : String(error)
console.error(`Qualification gate check failed: ${message}`)
process.exitCode = 1
}
}
147 changes: 147 additions & 0 deletions .github/scripts/classify-release-tag.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,147 @@
import { readFileSync } from 'node:fs'
import { resolve } from 'node:path'
import { fileURLToPath } from 'node:url'

const NUMERIC_IDENTIFIER = '(?:0|[1-9][0-9]*)'
const CORE_VERSION = `(${NUMERIC_IDENTIFIER})\\.(${NUMERIC_IDENTIFIER})\\.(${NUMERIC_IDENTIFIER})`
const STABLE_TAG_PATTERN = new RegExp(`^v${CORE_VERSION}$`)
const PRERELEASE_TAG_PATTERN = new RegExp(`^v${CORE_VERSION}-(beta|rc|next)\\.(${NUMERIC_IDENTIFIER})$`)
const VERSIONED_PRERELEASE_PREFIX_PATTERN = new RegExp(`^v${CORE_VERSION}-`)

function fail(message) {
throw new Error(message)
}

export function classifyReleaseTag(tag) {
if (typeof tag !== 'string' || tag.length === 0) {
fail('A release tag is required (for example, v1.2.3 or v1.2.3-beta.1)')
}

if (STABLE_TAG_PATTERN.test(tag)) {
return {
channel: 'stable',
tag,
version: tag.slice(1),
}
}

const prereleaseMatch = tag.match(PRERELEASE_TAG_PATTERN)
if (prereleaseMatch) {
return {
channel: 'prerelease',
prerelease: `${prereleaseMatch[4]}.${prereleaseMatch[5]}`,
tag,
version: tag.slice(1),
}
}

if (VERSIONED_PRERELEASE_PREFIX_PATTERN.test(tag)) {
fail(`Invalid prerelease tag "${tag}": approved forms are -beta.N, -rc.N, or -next.N with a non-negative integer N`)
}

fail(`Malformed release tag "${tag}": expected vMAJOR.MINOR.PATCH or an approved prerelease tag`)
}

export function assertExpectedChannel(classification, expectedChannel) {
if (expectedChannel !== 'stable' && expectedChannel !== 'prerelease') {
fail(`Unknown expected channel "${expectedChannel}": use stable or prerelease`)
}

if (classification.channel !== expectedChannel) {
fail(`Tag ${classification.tag} is ${classification.channel}; this release path requires a ${expectedChannel} tag`)
}
}

export function assertTagMatchesPackageVersion(classification, packageVersion) {
if (typeof packageVersion !== 'string' || packageVersion.length === 0) {
fail('package.json must contain a non-empty version field')
}

if (classification.version !== packageVersion) {
fail(`Tag ${classification.tag} does not match package.json version ${packageVersion}`)
}
}

export function assertChangelogContainsVersion(version, changelog) {
const escapedVersion = version.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
const headingPattern = new RegExp(`^## \\[${escapedVersion}\\](?:\\s|$)`, 'm')

if (!headingPattern.test(changelog)) {
fail(`CHANGELOG.md is missing a ## [${version}] section`)
}
}

function parseArguments(args) {
const options = {
changelogPath: 'CHANGELOG.md',
packageJsonPath: 'package.json',
verifyChangelog: false,
verifyPackageVersion: false,
}

for (let index = 0; index < args.length; index += 1) {
const argument = args[index]

if (argument === '--verify-package-version') {
options.verifyPackageVersion = true
continue
}
if (argument === '--verify-changelog') {
options.verifyChangelog = true
continue
}

const value = args[index + 1]
if (!value || value.startsWith('--')) {
fail(`${argument} requires a value`)
}

if (argument === '--tag') {
options.tag = value
} else if (argument === '--expect') {
options.expectedChannel = value
} else if (argument === '--package-json') {
options.packageJsonPath = value
} else if (argument === '--changelog') {
options.changelogPath = value
} else {
fail(`Unknown argument "${argument}"`)
}
index += 1
}

return options
}

export function runCli(args) {
const options = parseArguments(args)
const classification = classifyReleaseTag(options.tag)

if (options.expectedChannel) {
assertExpectedChannel(classification, options.expectedChannel)
}

if (options.verifyPackageVersion) {
const packageManifest = JSON.parse(readFileSync(resolve(options.packageJsonPath), 'utf8'))
assertTagMatchesPackageVersion(classification, packageManifest.version)
}

if (options.verifyChangelog) {
const changelog = readFileSync(resolve(options.changelogPath), 'utf8')
assertChangelogContainsVersion(classification.version, changelog)
}

console.log(`channel=${classification.channel}`)
console.log(`version=${classification.version}`)
}

const isCli = process.argv[1] && fileURLToPath(import.meta.url) === resolve(process.argv[1])
if (isCli) {
try {
runCli(process.argv.slice(2))
} catch (error) {
const message = error instanceof Error ? error.message : String(error)
console.error(`Release tag validation failed: ${message}`)
process.exitCode = 1
}
}
Loading
Loading