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;
+}