diff --git a/tools/consumer-smoke.test.ts b/tools/consumer-smoke.test.ts new file mode 100644 index 000000000..801c5fdce --- /dev/null +++ b/tools/consumer-smoke.test.ts @@ -0,0 +1,179 @@ +/** + * Hostile decision-logic tests for consumer-smoke (#1216, A10.8 / H6). + * + * The npm availability probe is a release gate: it is wired into the + * post-publish release plan (tools/autoflow/release.ts) and the published + * consumer workflow (.github/workflows/published-consumers.yml). Only a + * CONFIRMED registry 200 whose body confirms the exact version may admit the + * release. Confirmed absence (404) is FAIL; every infra uncertainty — + * timeout, DNS/network exception, 5xx, redirect, malformed or inconsistent + * payload — is UNKNOWN and fails closed (non-zero exit). No path may turn + * infra uncertainty into PASS or SKIP. + */ + +import { assertEquals } from '@std/assert'; +import { admitsRelease, releaseGateExitCode } from './gate-verdict.ts'; +import { + cdnAvailabilityDecision, + classifyRegistryResponse, + npmAvailabilityDecision, + type RegistryFetcher, +} from './consumer-smoke.ts'; + +const NAME = '@openelement/element'; +const VERSION = '0.44.0-alpha.1'; + +function fetcherReturning(status: number, body: unknown): RegistryFetcher { + return () => Promise.resolve({ status, body: String(body) }); +} + +function fetcherThrowing(error: Error): RegistryFetcher { + return () => Promise.reject(error); +} + +Deno.test('consumer-smoke registry probe: confirmed 200 with matching version is the only PASS', async () => { + const decision = await npmAvailabilityDecision( + NAME, + VERSION, + fetcherReturning(200, JSON.stringify({ name: NAME, version: VERSION })), + ); + assertEquals(decision.verdict, 'PASS'); + assertEquals(admitsRelease(decision), true); + assertEquals(releaseGateExitCode(decision), 0); +}); + +Deno.test('consumer-smoke registry probe: confirmed 404 is FAIL (not a silent skip)', async () => { + const decision = await npmAvailabilityDecision(NAME, VERSION, fetcherReturning(404, '{}')); + assertEquals(decision.verdict, 'FAIL'); + assertEquals(releaseGateExitCode(decision), 1); +}); + +Deno.test('consumer-smoke registry probe: 5xx is UNKNOWN and fails closed', async () => { + for (const status of [500, 502, 503]) { + const decision = await npmAvailabilityDecision( + NAME, + VERSION, + fetcherReturning(status, 'upstream error'), + ); + assertEquals(decision.verdict, 'UNKNOWN', `status ${status}`); + assertEquals(releaseGateExitCode(decision), 1); + } +}); + +Deno.test('consumer-smoke registry probe: redirects and other statuses are UNKNOWN', async () => { + for (const status of [301, 403, 418]) { + const decision = await npmAvailabilityDecision(NAME, VERSION, fetcherReturning(status, '')); + assertEquals(decision.verdict, 'UNKNOWN', `status ${status}`); + assertEquals(admitsRelease(decision), false); + } +}); + +Deno.test('consumer-smoke registry probe: DNS/network exception is UNKNOWN and fails closed', async () => { + const decision = await npmAvailabilityDecision( + NAME, + VERSION, + fetcherThrowing(new TypeError('getaddrinfo ENOTFOUND registry.npmjs.org')), + ); + assertEquals(decision.verdict, 'UNKNOWN'); + assertEquals(releaseGateExitCode(decision), 1); +}); + +Deno.test('consumer-smoke registry probe: timeout is UNKNOWN and fails closed', async () => { + const decision = await npmAvailabilityDecision( + NAME, + VERSION, + fetcherThrowing(new DOMException('The operation timed out', 'TimeoutError')), + ); + assertEquals(decision.verdict, 'UNKNOWN'); + assertEquals(releaseGateExitCode(decision), 1); +}); + +Deno.test('consumer-smoke registry probe: malformed JSON on 200 is UNKNOWN, never PASS', async () => { + const decision = await npmAvailabilityDecision( + NAME, + VERSION, + fetcherReturning(200, 'proxy error'), + ); + assertEquals(decision.verdict, 'UNKNOWN'); + assertEquals(releaseGateExitCode(decision), 1); +}); + +Deno.test('consumer-smoke registry probe: 200 whose payload does not confirm the version is UNKNOWN', async () => { + for (const body of ['{}', JSON.stringify({ version: '0.0.0-other' }), '[]', '"ok"', '42']) { + const decision = await npmAvailabilityDecision(NAME, VERSION, fetcherReturning(200, body)); + assertEquals(decision.verdict, 'UNKNOWN', `body ${body}`); + assertEquals(admitsRelease(decision), false); + } +}); + +Deno.test('consumer-smoke registry classification: pure classifier mirrors the probe verdicts', () => { + assertEquals( + classifyRegistryResponse(NAME, VERSION, 200, JSON.stringify({ version: VERSION })).verdict, + 'PASS', + ); + assertEquals(classifyRegistryResponse(NAME, VERSION, 404, '{}').verdict, 'FAIL'); + assertEquals(classifyRegistryResponse(NAME, VERSION, 500, '').verdict, 'UNKNOWN'); + assertEquals(classifyRegistryResponse(NAME, VERSION, 200, 'not json').verdict, 'UNKNOWN'); +}); + +Deno.test('consumer-smoke CDN probe: package published but CDN artifact missing is FAIL', async () => { + const decision = await cdnAvailabilityDecision(VERSION, fetcherReturning(404, 'Not found')); + assertEquals(decision.verdict, 'FAIL'); + assertEquals(releaseGateExitCode(decision), 1); +}); + +Deno.test('consumer-smoke CDN probe: CDN 5xx / network failure is UNKNOWN and fails closed', async () => { + const serverError = await cdnAvailabilityDecision(VERSION, fetcherReturning(503, '')); + assertEquals(serverError.verdict, 'UNKNOWN'); + assertEquals(releaseGateExitCode(serverError), 1); + + const networkError = await cdnAvailabilityDecision( + VERSION, + fetcherThrowing(new TypeError('network unreachable')), + ); + assertEquals(networkError.verdict, 'UNKNOWN'); + assertEquals(releaseGateExitCode(networkError), 1); +}); + +Deno.test('consumer-smoke CDN probe: confirmed 200 with a non-empty export is PASS; empty body is FAIL', async () => { + const ok = await cdnAvailabilityDecision(VERSION, fetcherReturning(200, 'export{/* esm */};')); + assertEquals(ok.verdict, 'PASS'); + assertEquals(releaseGateExitCode(ok), 0); + + const empty = await cdnAvailabilityDecision(VERSION, fetcherReturning(200, ' \n ')); + assertEquals(empty.verdict, 'FAIL'); + assertEquals(releaseGateExitCode(empty), 1); +}); + +Deno.test('consumer-smoke: no hostile registry input maps to PASS or SKIP — every uncertainty exits non-zero', async () => { + const hostile: Array<[string, RegistryFetcher]> = [ + ['404', fetcherReturning(404, '{}')], + ['500', fetcherReturning(500, '')], + ['timeout', fetcherThrowing(new DOMException('timed out', 'TimeoutError'))], + ['dns', fetcherThrowing(new TypeError('ENOTFOUND'))], + ['malformed', fetcherReturning(200, '{')], + ['version-mismatch', fetcherReturning(200, JSON.stringify({ version: '9.9.9' }))], + ]; + for (const [label, fetcher] of hostile) { + const registry = await npmAvailabilityDecision(NAME, VERSION, fetcher); + assertEquals(registry.verdict === 'PASS' || registry.verdict === 'SKIP_ALLOWED', false, label); + assertEquals(releaseGateExitCode(registry), 1, label); + } +}); + +Deno.test('consumer-smoke: no hostile CDN input maps to PASS or SKIP — every uncertainty exits non-zero', async () => { + // The CDN serves JS text, not JSON: body shape is not evidence. Only status, + // non-emptiness and probe success classify the verdict. + const hostile: Array<[string, RegistryFetcher]> = [ + ['404-artifact-missing', fetcherReturning(404, 'Not found')], + ['500', fetcherReturning(500, '')], + ['timeout', fetcherThrowing(new DOMException('timed out', 'TimeoutError'))], + ['dns', fetcherThrowing(new TypeError('ENOTFOUND'))], + ['empty-200', fetcherReturning(200, ' ')], + ]; + for (const [label, fetcher] of hostile) { + const cdn = await cdnAvailabilityDecision(VERSION, fetcher); + assertEquals(cdn.verdict === 'PASS' || cdn.verdict === 'SKIP_ALLOWED', false, label); + assertEquals(releaseGateExitCode(cdn), 1, label); + } +}); diff --git a/tools/consumer-smoke.ts b/tools/consumer-smoke.ts index 08c392269..4d1678541 100644 --- a/tools/consumer-smoke.ts +++ b/tools/consumer-smoke.ts @@ -6,6 +6,16 @@ * verifies @openelement/element can be consumed from npm in Deno and Node. * Also checks the jsDelivr CDN browser-safe export and Nitro build output. * + * This script is a RELEASE GATE (#1216, A10.8): it is wired into the + * post-publish release plan (tools/autoflow/release.ts) and the published + * consumer workflow (.github/workflows/published-consumers.yml), so its + * availability probes use the canonical verdict contract + * (tools/gate-verdict.ts) and fail closed. Only a CONFIRMED registry 200 + * whose payload confirms the exact version admits the release; a confirmed + * 404 is FAIL; timeout, DNS/network failure, 5xx and malformed responses are + * UNKNOWN — all non-PASS verdicts exit non-zero. There is no skip path: + * infra uncertainty can never green a release. + * * Usage: * deno run -A tools/consumer-smoke.ts * deno run -A tools/consumer-smoke.ts --local @@ -13,6 +23,8 @@ * deno run -A tools/consumer-smoke.ts --version --jsdelivr --nitro */ +import { formatError } from '@openelement/element'; +import { admitsRelease, fail, type GateDecision, pass, unknown } from './gate-verdict.ts'; import { getArg, runWithOutput } from './lib/process.ts'; import { readJson } from './lib/fs.ts'; import { normalizeSlashes } from './lib/path.ts'; @@ -247,25 +259,125 @@ async function exactVersionStarterSmoke(version: string): Promise { } } -async function jsdelivrSmoke(version: string): Promise { - const url = `https://cdn.jsdelivr.net/npm/@openelement/element@${version}/+esm`; - console.log(`\n[jsDelivr CDN browser-safe export] ${url}`); +/** Injectable HTTP probe shape: status code plus raw body text. */ +export interface RegistryFetchResponse { + status: number; + body: string; +} - const response = await fetch(url); - const text = await response.text(); +export type RegistryFetcher = (url: string) => Promise; - if (response.status !== 200) { - console.error(` failed: status ${response.status}`); - console.error(text.slice(0, 500)); - Deno.exit(1); +const PROBE_TIMEOUT_MS = 15_000; + +/** Real network probe; the only IO behind the availability decisions. */ +async function httpProbe(url: string): Promise { + const response = await fetch(url, { signal: AbortSignal.timeout(PROBE_TIMEOUT_MS) }); + return { status: response.status, body: await response.text() }; +} + +/** + * Classify a registry `GET /{name}/{version}` response. PASS requires a 200 + * whose JSON payload confirms the exact requested version; a 404 is confirmed + * absence (FAIL); every other status, a malformed body, or a payload that + * does not confirm the version is UNKNOWN — infra uncertainty, fail closed. + */ +export function classifyRegistryResponse( + name: string, + version: string, + status: number, + body: string, +): GateDecision { + if (status === 404) { + return fail(`confirmed absence: ${name}@${version} is not published on npm (registry 404)`); } + if (status !== 200) { + return unknown( + `registry returned HTTP ${status} for ${name}@${version}; availability cannot be confirmed`, + ); + } + let parsed: unknown; + try { + parsed = JSON.parse(body) as unknown; + } catch { + return unknown(`malformed registry response for ${name}@${version}; not valid JSON`); + } + if ( + typeof parsed !== 'object' || parsed === null || Array.isArray(parsed) || + (parsed as { version?: unknown }).version !== version + ) { + return unknown( + `registry response for ${name}@${version} does not confirm version ${version}`, + ); + } + return pass(`${name}@${version} confirmed on npm (registry 200, version payload match)`); +} - if (text.trim().length === 0) { - console.error(' failed: empty response'); - Deno.exit(1); +/** + * npm availability verdict for the release gate. Network exceptions + * (DNS failure, timeout, reset) are UNKNOWN, never "absent". + */ +export async function npmAvailabilityDecision( + name: string, + version: string, + fetcher: RegistryFetcher = httpProbe, +): Promise { + const url = `https://registry.npmjs.org/${name}/${version}`; + let response: RegistryFetchResponse; + try { + response = await fetcher(url); + } catch (error) { + return unknown(`registry probe for ${name}@${version} failed: ${formatError(error)}`); } + return classifyRegistryResponse(name, version, response.status, response.body); +} - console.log(` ok: ${text.length} bytes`); +/** + * Classify a jsDelivr CDN response for the browser-safe export. PASS requires + * a 200 with a non-empty body; a 404 means the CDN artifact for a published + * package is missing (FAIL); anything else is UNKNOWN. + */ +export function classifyCdnResponse(version: string, status: number, body: string): GateDecision { + if (status === 404) { + return fail(`CDN artifact missing: jsDelivr 404 for @openelement/element@${version}/+esm`); + } + if (status !== 200) { + return unknown( + `jsDelivr returned HTTP ${status} for @openelement/element@${version}; CDN availability cannot be confirmed`, + ); + } + if (body.trim().length === 0) { + return fail( + `jsDelivr returned an empty browser-safe export for @openelement/element@${version}`, + ); + } + return pass(`jsDelivr browser-safe export confirmed for @openelement/element@${version}`); +} + +/** jsDelivr CDN availability verdict for the release gate. */ +export async function cdnAvailabilityDecision( + version: string, + fetcher: RegistryFetcher = httpProbe, +): Promise { + const url = `https://cdn.jsdelivr.net/npm/@openelement/element@${version}/+esm`; + let response: RegistryFetchResponse; + try { + response = await fetcher(url); + } catch (error) { + return unknown( + `jsDelivr probe for @openelement/element@${version} failed: ${formatError(error)}`, + ); + } + return classifyCdnResponse(version, response.status, response.body); +} + +async function jsdelivrSmoke(version: string): Promise { + console.log(`\n[jsDelivr CDN browser-safe export] @openelement/element@${version}/+esm`); + const decision = await cdnAvailabilityDecision(version); + if (!admitsRelease(decision)) { + console.error(` ${decision.verdict}: ${decision.reason}`); + Deno.exit(1); + } + console.log(` ok: ${decision.reason}`); } async function nitroSmoke(): Promise { @@ -288,15 +400,6 @@ async function nitroSmoke(): Promise { } } -async function npmPackageExists(name: string, version: string): Promise { - try { - const response = await fetch(`https://registry.npmjs.org/${name}/${version}`); - return response.status === 200; - } catch { - return false; - } -} - async function main(): Promise { const { PACKAGE_VERSION } = await import('./project-constants.ts'); const local = getArgFlag('--local'); @@ -316,14 +419,17 @@ async function main(): Promise { if (runNitro) console.log(' + Nitro output smoke'); if (!local) { - const exists = await npmPackageExists('@openelement/element', version); - if (!exists) { - console.log( - `\n@openelement/element@${version} is not yet available on npm; skipping npm consumer smoke.`, - ); - console.log('Run again after publish, or use --local to test against workspace sources.'); - return; + // Release gate, fail closed (#1216): only a confirmed registry 200 with a + // matching version payload admits the smoke. A confirmed 404 is FAIL; + // timeout/DNS/5xx/malformed responses are UNKNOWN. Both exit non-zero — + // infra uncertainty can no longer skip this gate green. + const availability = await npmAvailabilityDecision('@openelement/element', version); + if (!admitsRelease(availability)) { + console.error(`\nnpm availability gate: ${availability.verdict}: ${availability.reason}`); + console.error('Use --local to smoke against workspace sources instead.'); + Deno.exit(1); } + console.log(` npm availability: ${availability.reason}`); } await denoNpmSmoke(version, projectRoot, local); diff --git a/tools/gate-verdict.test.ts b/tools/gate-verdict.test.ts new file mode 100644 index 000000000..269cadcb3 --- /dev/null +++ b/tools/gate-verdict.test.ts @@ -0,0 +1,48 @@ +/** + * Canonical release-gate verdict contract tests (#1216, A10.8). + * + * A release gate is production code: only PASS admits a release; UNKNOWN + * (infra uncertainty) and FAIL always fail closed; SKIP_ALLOWED admits only + * when release policy explicitly allows a skip. + */ + +import { assertEquals } from '@std/assert'; +import { + admitsRelease, + fail, + pass, + releaseGateExitCode, + skipAllowed, + unknown, +} from './gate-verdict.ts'; + +Deno.test('gate-verdict: only PASS admits a release by default', () => { + assertEquals(admitsRelease(pass('confirmed')), true); + assertEquals(admitsRelease(fail('confirmed absence')), false); + assertEquals(admitsRelease(unknown('registry timeout')), false); + assertEquals(admitsRelease(skipAllowed('infra absent locally')), false); +}); + +Deno.test('gate-verdict: SKIP_ALLOWED admits only when release policy explicitly allows skips', () => { + const skip = skipAllowed('policy-sanctioned skip'); + assertEquals(admitsRelease(skip, { allowSkip: false }), false); + assertEquals(admitsRelease(skip, { allowSkip: true }), true); + // Policy never rescues FAIL or UNKNOWN. + assertEquals(admitsRelease(fail('x'), { allowSkip: true }), false); + assertEquals(admitsRelease(unknown('x'), { allowSkip: true }), false); +}); + +Deno.test('gate-verdict: exit code is 0 only for admitted verdicts', () => { + assertEquals(releaseGateExitCode(pass('ok')), 0); + assertEquals(releaseGateExitCode(fail('no')), 1); + assertEquals(releaseGateExitCode(unknown('timeout')), 1); + assertEquals(releaseGateExitCode(skipAllowed('skip')), 1); + assertEquals(releaseGateExitCode(skipAllowed('skip'), { allowSkip: true }), 0); +}); + +Deno.test('gate-verdict: decisions carry a human-readable reason', () => { + assertEquals(pass('p').reason, 'p'); + assertEquals(fail('f').reason, 'f'); + assertEquals(unknown('u').reason, 'u'); + assertEquals(skipAllowed('s').reason, 's'); +}); diff --git a/tools/gate-verdict.ts b/tools/gate-verdict.ts new file mode 100644 index 000000000..b33ee294c --- /dev/null +++ b/tools/gate-verdict.ts @@ -0,0 +1,85 @@ +/** + * Canonical release-gate verdict contract (#1216, A10.8; umbrella #1155; + * ADR-0151). + * + * A release gate is production code. Ad-hoc boolean results collapse + * confirmed failure and infrastructure uncertainty into the same value, which + * is how `catch { return false }` once turned a registry outage into a + * silently passing post-publish gate (H6). This module is the one shared + * verdict vocabulary for release-critical gates under tools/: + * + * - PASS — the gate's claim is positively confirmed by evidence. + * - FAIL — the gate's claim is positively refuted (e.g. a confirmed + * registry 404, a missing release artifact, stale evidence). + * - SKIP_ALLOWED — the gate did not run and release policy explicitly + * sanctions the skip (e.g. infra genuinely absent outside + * CI, as in check-critical-path-tests.ts). Never produced + * for uncertainty; requires an affirmative policy decision. + * - UNKNOWN — infrastructure uncertainty: timeout, DNS/network + * failure, 5xx, malformed or inconsistent response. NOTHING + * can be concluded about the gate's claim. + * + * Release admission is fail closed: only PASS admits by default; FAIL and + * UNKNOWN always block; SKIP_ALLOWED blocks unless the caller passes an + * explicit `{ allowSkip: true }` policy. There is no path from UNKNOWN to + * PASS or SKIP. + */ + +export type GateVerdict = 'PASS' | 'FAIL' | 'SKIP_ALLOWED' | 'UNKNOWN'; + +export interface GateDecision { + readonly verdict: GateVerdict; + /** Human-readable evidence or diagnostic behind the verdict. */ + readonly reason: string; +} + +export function pass(reason: string): GateDecision { + return { verdict: 'PASS', reason }; +} + +export function fail(reason: string): GateDecision { + return { verdict: 'FAIL', reason }; +} + +export function skipAllowed(reason: string): GateDecision { + return { verdict: 'SKIP_ALLOWED', reason }; +} + +export function unknown(reason: string): GateDecision { + return { verdict: 'UNKNOWN', reason }; +} + +export interface ReleaseAdmissionPolicy { + /** + * Admit a SKIP_ALLOWED verdict. Defaults to false: a skip only ever admits + * a release when the release policy for that gate explicitly says so. + */ + readonly allowSkip?: boolean; +} + +/** + * Release admission, fail closed: PASS admits; SKIP_ALLOWED admits only under + * an explicit allow-skip policy; FAIL and UNKNOWN never admit. + */ +export function admitsRelease( + decision: GateDecision, + policy: ReleaseAdmissionPolicy = {}, +): boolean { + switch (decision.verdict) { + case 'PASS': + return true; + case 'SKIP_ALLOWED': + return policy.allowSkip === true; + case 'FAIL': + case 'UNKNOWN': + return false; + } +} + +/** Process exit code for a release gate: 0 only when the release is admitted. */ +export function releaseGateExitCode( + decision: GateDecision, + policy: ReleaseAdmissionPolicy = {}, +): 0 | 1 { + return admitsRelease(decision, policy) ? 0 : 1; +}