import { constants, type Stats } from "node:fs"; import { lstat, open, realpath } from "node:fs/promises"; import path from "node:path"; import { z, type ZodType } from "zod"; import type { CiGateArtifact, CiGateArtifactSchema, } from "../contracts/ci-gates.ts"; import { ciContractReportSchema } from "./ci-contract-report.ts"; import { architectureDependencyReportArtifactSchema, automatedA11yArtifactSchema, buildManifestArtifactSchema, bundlePerformanceArtifactSchema, compatibilityFixturesArtifactSchema, dependencyDiffArtifactSchema, dependencyInventoryArtifactSchema, designSystemReportArtifactSchema, diagnosticsReportArtifactSchema, documentationReviewArtifactSchema, fieldWebVitalsArtifactSchema, hostingHeadersArtifactSchema, i18nReportArtifactSchema, jsonSchemaDocumentArtifactSchema, labPerformanceArtifactSchema, licenseReportArtifactSchema, manualA11yReportArtifactSchema, moduleInventoryArtifactSchema, optionalRecipeFixturesArtifactSchema, optionalRecipesArtifactSchema, provenanceArtifactSchema, realtimeBoundariesArtifactSchema, registryGovernanceRunArtifactSchema, registryCompatibilityFixturesArtifactSchema, registrySnapshotArtifactSchema, deploymentAdmissionArtifactSchema, releaseVerificationArtifactSchema, reproducibleBuildArtifactSchema, runbookRecordArtifactSchema, sbomArtifactSchema, supplyChainFixturesArtifactSchema, supplyChainProviderFixturesArtifactSchema, supplyChainVerificationArtifactSchema, vulnerabilityReportArtifactSchema, } from "../contracts/release-artifacts.ts"; import { httpScenarioReceiptSchema } from "./http-scenario-evidence.ts"; import { supplyChainCoherenceReportSchema } from "./local-release-evidence.ts"; import { providerVerificationArtifactSchema, provenanceProviderAttestationSchema, vulnerabilityProviderReportSchema, } from "./provider-evidence.ts"; import { releaseCandidateManifestSchema } from "./release-candidate.ts"; import { normalizeRepositoryRelativePath } from "./repository-file-inventory.ts"; import { secretScanSarifSchema } from "./secret-scan-evaluator.ts"; import { testEvidenceReportSchema } from "./test-evidence-artifact.ts"; const coverageCounterSchema = z .object({ total: z.number().int().nonnegative(), covered: z.number().int().nonnegative(), skipped: z.number().int().nonnegative(), pct: z.number().min(0).max(100), }) .strict() .superRefine((counter, context) => { if (counter.covered + counter.skipped > counter.total) { context.addIssue({ code: "custom", message: "coverage counter exceeds total" }); } const expected = counter.total === 0 ? 100 : Math.floor((counter.covered / counter.total) * 10_000) / 100; if (counter.pct !== expected) { context.addIssue({ code: "custom", path: ["pct"], message: "coverage pct is not exact" }); } }); const coverageSummarySchema = z .record( z.string(), z .object({ lines: coverageCounterSchema, statements: coverageCounterSchema, functions: coverageCounterSchema, branches: coverageCounterSchema, }) .strict(), ) .refine((value) => "total" in value, "coverage summary lacks total"); const riskCoverageArtifactSchema = z .object({ schemaVersion: z.literal(3), policy: z.string().min(1), summary: z.string().min(1), status: z.enum(["PASS", "FAIL"]), selectedTotal: z.number().int().nonnegative(), repositoryTotal: z.number().int().positive(), counterBearingTotal: z.number().int().nonnegative(), instrumentedCounterBearingTotal: z.number().int().nonnegative(), counterlessTotal: z.number().int().nonnegative(), counterlessModules: z.array(z.string()), preExclusionTotal: z.number().int().positive(), generatedExclusionCount: z.number().int().nonnegative(), generatedExclusions: z.array(z.string()), ownershipScope: z.literal("ALL_POLICY_HIGH_RISK"), ownedHighRiskPaths: z.array(z.string()), waivedHighRiskPaths: z.array(z.string()), uncoveredModules: z.array(z.string()), results: z .array( z .object({ scope: z.string().min(1), metric: z.enum(["lines", "statements", "functions", "branches"]), threshold: z.number().min(0).max(100), received: z.number().min(0).max(100), passed: z.boolean(), }) .strict(), ) .min(4), failures: z.array(z.string()), }) .strict() .superRefine((artifact, context) => { const fail = (path: PropertyKey[], message: string) => context.addIssue({ code: "custom", path, message }); if (artifact.counterBearingTotal + artifact.counterlessTotal !== artifact.repositoryTotal) { fail(["counterBearingTotal"], "counter partition must equal repositoryTotal"); } if (artifact.instrumentedCounterBearingTotal > artifact.counterBearingTotal) { fail(["instrumentedCounterBearingTotal"], "instrumented counters exceed counter-bearing total"); } if (artifact.counterlessModules.length !== artifact.counterlessTotal) { fail(["counterlessModules"], "counterless list length drift"); } if (artifact.generatedExclusions.length !== artifact.generatedExclusionCount) { fail(["generatedExclusions"], "generated exclusion list length drift"); } if ( artifact.preExclusionTotal !== artifact.repositoryTotal + artifact.generatedExclusionCount ) { fail(["preExclusionTotal"], "pre-exclusion inventory total drift"); } if ( artifact.selectedTotal > artifact.repositoryTotal || artifact.uncoveredModules.length !== artifact.repositoryTotal - artifact.selectedTotal ) { fail(["selectedTotal"], "selected/uncovered repository totals drift"); } if ( (artifact.status === "PASS") !== (artifact.failures.length === 0 && artifact.results.every(({ passed }) => passed)) ) { fail(["status"], "status must agree with failures and threshold results"); } artifact.results.forEach((result, index) => { if (result.passed !== (result.received >= result.threshold)) { fail(["results", index, "passed"], "threshold result is inconsistent"); } }); for (const [field, values] of [ ["counterlessModules", artifact.counterlessModules], ["generatedExclusions", artifact.generatedExclusions], ["ownedHighRiskPaths", artifact.ownedHighRiskPaths], ["waivedHighRiskPaths", artifact.waivedHighRiskPaths], ["uncoveredModules", artifact.uncoveredModules], ] as const) { if (new Set(values).size !== values.length) fail([field], "path list contains duplicates"); } const owned = new Set(artifact.ownedHighRiskPaths); if (artifact.waivedHighRiskPaths.some((modulePath) => owned.has(modulePath))) { fail(["waivedHighRiskPaths"], "owned and waived high-risk paths overlap"); } const resultsByScope = new Map>(); artifact.results.forEach(({ scope, metric }, index) => { const metrics = resultsByScope.get(scope) ?? new Set(); if (metrics.has(metric)) { fail(["results", index, "metric"], "threshold metric is duplicated within scope"); } metrics.add(metric); resultsByScope.set(scope, metrics); }); for (const [scope, metrics] of resultsByScope) { if (metrics.size !== 4) { fail(["results"], `threshold scope must contain all four metrics: ${scope}`); } } }); type ExecutableJsonSchemaId = Extract< CiGateArtifactSchema, Readonly<{ kind: "json" }> >["executableSchemaId"]; const executableJsonSchemas: Readonly> = Object.freeze({ "automated-a11y": automatedA11yArtifactSchema, "manual-a11y": manualA11yReportArtifactSchema, "architecture-dependency-report": architectureDependencyReportArtifactSchema, "design-system-contract": designSystemReportArtifactSchema, "i18n-contract": i18nReportArtifactSchema, "diagnostics-contract": diagnosticsReportArtifactSchema, "realtime-boundaries": realtimeBoundariesArtifactSchema, "optional-recipes": optionalRecipesArtifactSchema, "optional-recipe-fixtures": optionalRecipeFixturesArtifactSchema, "registry-compatibility-fixtures": registryCompatibilityFixturesArtifactSchema, "reproducible-build": reproducibleBuildArtifactSchema, "supply-chain-fixtures": supplyChainFixturesArtifactSchema, "supply-chain-provider-fixtures": supplyChainProviderFixturesArtifactSchema, "compatibility-fixtures": compatibilityFixturesArtifactSchema, "documentation-review": documentationReviewArtifactSchema, "hosting-headers": hostingHeadersArtifactSchema, "coverage-summary-v8": coverageSummarySchema, "risk-coverage-v3": riskCoverageArtifactSchema, "build-manifest": buildManifestArtifactSchema, "module-inventory": moduleInventoryArtifactSchema, "dependency-inventory": dependencyInventoryArtifactSchema, "registry-snapshot": registrySnapshotArtifactSchema, "registry-governance-run": registryGovernanceRunArtifactSchema, "bundle-performance": bundlePerformanceArtifactSchema, sbom: sbomArtifactSchema, provenance: provenanceArtifactSchema, "dependency-diff": dependencyDiffArtifactSchema, "license-report": licenseReportArtifactSchema, "vulnerability-report": vulnerabilityReportArtifactSchema, "field-web-vitals": fieldWebVitalsArtifactSchema, "lab-performance": labPerformanceArtifactSchema, "release-verification": releaseVerificationArtifactSchema, "runbook-record": runbookRecordArtifactSchema, "supply-chain-verification": supplyChainVerificationArtifactSchema, "release-candidate": releaseCandidateManifestSchema, "supply-chain-coherence": supplyChainCoherenceReportSchema, "http-scenario-receipt": httpScenarioReceiptSchema, "test-evidence-report": testEvidenceReportSchema, "provider-vulnerability": vulnerabilityProviderReportSchema, "provider-provenance": provenanceProviderAttestationSchema, "provider-verification": providerVerificationArtifactSchema, "ci-contract-report": ciContractReportSchema, "deployment-admission": deploymentAdmissionArtifactSchema, }); export function hasCiArtifactSemanticValidator( schema: CiGateArtifactSchema, ): boolean { return schema.kind !== "json" || schema.executableSchemaId in executableJsonSchemas; } type ReadHandle = Readonly<{ stat(): Promise; read( buffer: Buffer, offset: number, length: number, position: number, ): Promise>; close(): Promise; }>; type ValidatorDependencies = Readonly<{ lstatPath?: typeof lstat; realpathPath?: typeof realpath; openFile?: (target: string, flags: number) => Promise; }>; export async function validateCiArtifact( input: Readonly<{ root: string; artifact: CiGateArtifact; schema: CiGateArtifactSchema; }>, dependencies: ValidatorDependencies = {}, ): Promise { const relative = normalizeRepositoryRelativePath(input.artifact.path, "CI artifact path"); assertExtensionCoherence(relative, input.schema.kind); const bytes = await readBoundedRegularFile( { root: input.root, relativePath: relative, maxBytes: input.schema.maxBytes }, dependencies, ); if (input.schema.kind === "candidate-archive") return; const text = new TextDecoder("utf-8", { fatal: true }).decode(bytes); if (!text.trim()) throw new TypeError(`CI artifact is empty: ${relative}`); switch (input.schema.kind) { case "text": return; case "markdown": if (!/^#|\[[^\]]+\]|\S/u.test(text)) throw new TypeError(`invalid Markdown artifact: ${relative}`); return; case "html": assertWellFormedHtml(text, relative); return; case "junit": assertWellFormedJUnitXml(text, relative); return; case "sarif": secretScanSarifSchema.parse(JSON.parse(text) as unknown); return; case "json-schema": jsonSchemaDocumentArtifactSchema.parse(JSON.parse(text) as unknown); return; case "json": { const schema = executableJsonSchemas[input.schema.executableSchemaId]; if (!schema) throw new TypeError(`unknown executable artifact schema: ${input.schema.executableSchemaId}`); schema.parse(JSON.parse(text) as unknown); return; } } } export async function readBoundedRegularFile( input: Readonly<{ root: string; relativePath: string; maxBytes: number }>, dependencies: ValidatorDependencies = {}, ): Promise { const root = path.resolve(input.root); const relative = normalizeRepositoryRelativePath(input.relativePath, "bounded file path"); const maxBytes = input.maxBytes; if (!Number.isSafeInteger(maxBytes) || maxBytes < 1 || maxBytes > 268_435_456) { throw new RangeError("bounded file maximum must be within 1..268435456"); } const lstatPath = dependencies.lstatPath ?? lstat; const realpathPath = dependencies.realpathPath ?? realpath; const openFile = dependencies.openFile ?? (async (target, flags) => open(target, flags)); const rootMetadata = await lstatPath(root); if (rootMetadata.isSymbolicLink() || !rootMetadata.isDirectory()) { throw new TypeError("bounded file root is unsafe"); } const rootRealpath = await realpathPath(root); let ancestor = root; const segments = relative.split("/"); for (const segment of segments.slice(0, -1)) { ancestor = path.join(ancestor, segment); const metadata = await lstatPath(ancestor); if (metadata.isSymbolicLink() || !metadata.isDirectory()) { throw new TypeError(`CI artifact ancestor is unsafe: ${relative}`); } } const absolute = path.join(root, relative); const before = await lstatPath(absolute); if (before.isSymbolicLink() || !before.isFile()) { throw new TypeError(`CI artifact is not a regular file: ${relative}`); } if (before.size <= 0 || before.size > maxBytes) { throw new RangeError(`CI artifact size is outside 1..${maxBytes}: ${relative}`); } const resolved = await realpathPath(absolute); const outside = path.relative(rootRealpath, resolved); if (outside === ".." || outside.startsWith(`..${path.sep}`) || path.isAbsolute(outside)) { throw new TypeError(`CI artifact escapes repository: ${relative}`); } const handle = await openFile(absolute, constants.O_RDONLY | constants.O_NOFOLLOW); try { const opened = await handle.stat(); assertSameIdentity(before, opened, relative); const bytes = await readHandleBounded(handle, before.size, maxBytes, relative); const after = await handle.stat(); assertSameIdentity(opened, after, relative); if (bytes.byteLength <= 0 || bytes.byteLength > maxBytes || after.size !== bytes.byteLength) { throw new RangeError(`CI artifact changed size or exceeds bound: ${relative}`); } return bytes; } finally { await handle.close(); } } async function readHandleBounded( handle: ReadHandle, expectedSize: number, maxBytes: number, relative: string, ): Promise { const captured = Buffer.allocUnsafe(Math.min(maxBytes + 1, expectedSize + 1)); let offset = 0; while (offset < captured.byteLength) { const { bytesRead } = await handle.read( captured, offset, captured.byteLength - offset, offset, ); if (bytesRead === 0) break; offset += bytesRead; } if (offset !== expectedSize) { throw new RangeError(`CI artifact changed size or exceeds bound: ${relative}`); } return captured.subarray(0, offset); } const MAX_DOCUMENT_DEPTH = 256; const MAX_DOCUMENT_UNITS = 100_000; function assertWellFormedJUnitXml(source: string, relative: string): void { const invalid = () => new TypeError(`invalid JUnit artifact: ${relative}`); if (/") || !hasValidXmlEntities(text)) && text.length > 0) throw invalid(); if (text.length > 0 && ++units > MAX_DOCUMENT_UNITS) throw invalid(); if (open < 0) break; if (source.startsWith("", open + 4); if (close < 0 || source.slice(open + 4, close).includes("--")) throw invalid(); if (++units > MAX_DOCUMENT_UNITS) throw invalid(); cursor = close + 3; continue; } if (source.startsWith("", open + 9); if (stack.length === 0 || close < 0) throw invalid(); if (++units > MAX_DOCUMENT_UNITS) throw invalid(); cursor = close + 3; continue; } if (source.startsWith("", open + 2); if (root || close < 0) throw invalid(); const processingInstruction = source.slice(open, close + 2); const match = /^<\?([A-Za-z_][\w:.-]*)(?:\s+[\s\S]*?)?\?>$/u.exec( processingInstruction, ); if (!match) throw invalid(); if (match[1]!.toLowerCase() === "xml") { if ( declarationSeen || source.slice(0, open).trim() || !/^<\?xml\s+version\s*=\s*(["'])1\.0\1(?:\s+encoding\s*=\s*(["'])UTF-8\2)?\s*\?>$/u.test( processingInstruction, ) ) { throw invalid(); } declarationSeen = true; } if (++units > MAX_DOCUMENT_UNITS) throw invalid(); cursor = close + 2; continue; } if (source.startsWith("$/u.exec(tag); if (closing) { if (stack.pop() !== closing[1]) throw invalid(); if (stack.length === 0) rootClosed = true; if (++units > MAX_DOCUMENT_UNITS) throw invalid(); cursor = close + 1; continue; } const opening = /^<([A-Za-z_][\w:.-]*)([\s\S]*?)(\/?)>$/u.exec(tag); if (!opening || rootClosed || !hasValidXmlAttributes(opening[2] ?? "")) throw invalid(); root ??= opening[1]; if (opening[3] !== "/") { if (stack.length >= MAX_DOCUMENT_DEPTH) throw invalid(); stack.push(opening[1]!); } else if (stack.length === 0) rootClosed = true; if (++units > MAX_DOCUMENT_UNITS) throw invalid(); cursor = close + 1; } if (stack.length > 0 || !rootClosed || (root !== "testsuite" && root !== "testsuites")) { throw invalid(); } } function hasValidXmlAttributes(source: string): boolean { let remaining = source; const names = new Set(); while (remaining.length > 0) { if (!remaining.trim()) return true; const match = /^\s+([A-Za-z_:][\w:.-]*)\s*=\s*(?:"([^"<]*)"|'([^'<]*)')/u.exec(remaining); if (!match || names.has(match[1]!)) return false; if (!hasValidXmlEntities(match[2] ?? match[3] ?? "")) return false; names.add(match[1]!); remaining = remaining.slice(match[0].length); } return true; } function hasOnlyXmlCharacters(source: string): boolean { for (const character of source) { const codePoint = character.codePointAt(0)!; if ( codePoint !== 0x09 && codePoint !== 0x0a && codePoint !== 0x0d && (codePoint < 0x20 || (codePoint > 0xd7ff && codePoint < 0xe000) || (codePoint > 0xfffd && codePoint < 0x10000) || codePoint > 0x10ffff) ) { return false; } } return true; } function hasValidXmlEntities(source: string): boolean { let cursor = 0; while (cursor < source.length) { const ampersand = source.indexOf("&", cursor); if (ampersand < 0) return true; const semicolon = source.indexOf(";", ampersand + 1); if (semicolon < 0) return false; const entity = source.slice(ampersand + 1, semicolon); if (!["amp", "lt", "gt", "apos", "quot"].includes(entity)) { const decimal = /^#([0-9]+)$/u.exec(entity); const hexadecimal = /^#x([a-fA-F0-9]+)$/u.exec(entity); if (!decimal && !hexadecimal) return false; const codePoint = Number.parseInt((decimal ?? hexadecimal)![1]!, decimal ? 10 : 16); if ( !Number.isSafeInteger(codePoint) || (codePoint !== 0x09 && codePoint !== 0x0a && codePoint !== 0x0d && (codePoint < 0x20 || (codePoint > 0xd7ff && codePoint < 0xe000) || (codePoint > 0xfffd && codePoint < 0x10000) || codePoint > 0x10ffff)) ) { return false; } } cursor = semicolon + 1; } return true; } const HTML_VOID_ELEMENTS = new Set([ "area", "base", "br", "col", "embed", "hr", "img", "input", "link", "meta", "param", "source", "track", "wbr", ]); const HTML_RAW_TEXT_ELEMENTS = new Set(["script", "style", "textarea", "title"]); function assertWellFormedHtml(source: string, relative: string): void { const invalid = () => new TypeError(`invalid HTML artifact: ${relative}`); if (/]*\[/iu.test(source)) throw invalid(); const stack: string[] = []; let cursor = 0; let units = 0; let doctypeSeen = false; let rootSeen = false; let rootClosed = false; let playwrightPayloadSeen = false; while (cursor < source.length) { const rawElement = stack.at(-1); if (rawElement && HTML_RAW_TEXT_ELEMENTS.has(rawElement)) { const closingStart = source.toLowerCase().indexOf(` MAX_DOCUMENT_UNITS) throw invalid(); cursor = closingStart; } const open = source.indexOf("<", cursor); const text = source.slice(cursor, open < 0 ? source.length : open); if (stack.length === 0 && text.trim()) throw invalid(); if (text.length > 0 && ++units > MAX_DOCUMENT_UNITS) throw invalid(); if (open < 0) break; if (source.startsWith("", open + 4); if (close < 0 || source.slice(open + 4, close).includes("--")) throw invalid(); if (++units > MAX_DOCUMENT_UNITS) throw invalid(); cursor = close + 3; continue; } const declarationEnd = source.indexOf(">", open + 2); if (source.slice(open, open + 9).toLowerCase() === "" ) { throw invalid(); } doctypeSeen = true; if (++units > MAX_DOCUMENT_UNITS) throw invalid(); cursor = declarationEnd + 1; continue; } if (source.startsWith("$/u.exec(tag); if (closing) { const name = closing[1]!.toLowerCase(); if (stack.pop() !== name) throw invalid(); if (stack.length === 0) { if (name === "html") rootClosed = true; else if (name === "template" && playwrightPayloadSeen) { // Playwright emits its base64 report template after ; HTML5 // reparents this token into the document body. It is the sole // permitted generated-report sidecar and does not create a new root. } else { throw invalid(); } } if (++units > MAX_DOCUMENT_UNITS) throw invalid(); cursor = close + 1; continue; } const opening = /^<([A-Za-z][A-Za-z0-9:-]*)([\s\S]*?)(\/?)>$/u.exec(tag); if (!opening) throw invalid(); const name = opening[1]!.toLowerCase(); const attributes = parseHtmlAttributes(opening[2] ?? ""); if (!attributes || name.includes(":")) throw invalid(); if (!rootSeen) { if (name !== "html" || opening[3] === "/") throw invalid(); rootSeen = true; } else if (name === "html") { throw invalid(); } if (rootClosed && stack.length === 0) { if ( playwrightPayloadSeen || name !== "template" || attributes.get("id") !== "playwrightReportBase64" || opening[3] === "/" ) { throw invalid(); } playwrightPayloadSeen = true; } if (!HTML_VOID_ELEMENTS.has(name) && opening[3] !== "/") { if (stack.length >= MAX_DOCUMENT_DEPTH) throw invalid(); stack.push(name); } else if (stack.length === 0 && name === "html") { rootClosed = true; } if (++units > MAX_DOCUMENT_UNITS) throw invalid(); cursor = close + 1; } if (stack.length > 0 || !rootSeen || !rootClosed) throw invalid(); } function markupEnd(source: string, start: number): number { let quote: "\"" | "'" | undefined; for (let index = start; index < source.length; index += 1) { const character = source[index]; if (quote) { if (character === quote) quote = undefined; } else if (character === "\"" || character === "'") { quote = character; } else if (character === ">") { return index; } } return -1; } function parseHtmlAttributes(source: string): ReadonlyMap | null { const attributes = new Map(); let cursor = 0; while (cursor < source.length) { const whitespace = /^\s+/u.exec(source.slice(cursor)); if (!whitespace) return source.slice(cursor).trim() ? null : attributes; cursor += whitespace[0].length; if (cursor >= source.length) return attributes; const nameMatch = /^[A-Za-z_:][A-Za-z0-9:._-]*/u.exec(source.slice(cursor)); if (!nameMatch) return null; const name = nameMatch[0].toLowerCase(); if (attributes.has(name) || name.includes(":")) return null; cursor += nameMatch[0].length; const spacing = /^\s*/u.exec(source.slice(cursor))![0]; cursor += spacing.length; let value = ""; if (source[cursor] === "=") { cursor += 1; cursor += /^\s*/u.exec(source.slice(cursor))![0].length; const quote = source[cursor]; if (quote === "\"" || quote === "'") { const end = source.indexOf(quote, cursor + 1); if (end < 0) return null; value = source.slice(cursor + 1, end); if (value.includes("<")) return null; cursor = end + 1; } else { const unquoted = /^[^\s"'`=<>]+/u.exec(source.slice(cursor)); if (!unquoted) return null; value = unquoted[0]; cursor += value.length; } } attributes.set(name, value); } return attributes; } function assertSameIdentity(before: Stats, after: Stats, relative: string): void { if ( !Number.isSafeInteger(before.dev) || !Number.isSafeInteger(before.ino) || before.dev <= 0 || before.ino <= 0 || before.dev !== after.dev || before.ino !== after.ino || !after.isFile() ) { throw new TypeError(`CI artifact file identity changed: ${relative}`); } } function assertExtensionCoherence(relative: string, kind: CiGateArtifactSchema["kind"]): void { const valid = kind === "json" || kind === "json-schema" ? relative.endsWith(".json") : kind === "sarif" ? relative.endsWith(".sarif") : kind === "junit" ? relative.endsWith(".xml") : kind === "html" ? relative.endsWith(".html") : kind === "markdown" ? relative.endsWith(".md") : kind === "candidate-archive" ? relative.endsWith(".tar.gz") : !/\.(?:json|sarif|xml|html|md|tar\.gz)$/u.test(relative); if (!valid) throw new TypeError(`CI artifact extension/kind mismatch: ${relative} (${kind})`); }