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
16 changes: 7 additions & 9 deletions docs/RC_READINESS.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ This is the release-candidate audit for the current repository state. It is
deliberately evidence-based: a documented process is not marked passed until a
command, artifact, or human record exists.

| Gate | Evidence | Status on 2026-08-17 |
| Gate | Evidence | Status on 2026-08-18 |
| --- | --- | --- |
| Public API, token, and keyboard contract | `npm run check:api-contract`, `npm run check:tokens`, 28 component references, `API_CONTRACT.md`; fresh-install `npm run check` passed on 2026-08-17 | Passed in local CI-equivalent rerun |
| Chromium, Firefox, WebKit behavior | `npm run test:browser` with pinned Playwright engines (84/84 passed); `actionlint` validates workflow matrix | Passed in local CI-equivalent rerun |
Expand All @@ -16,18 +16,16 @@ command, artifact, or human record exists.
| P0/P1 and security | GitHub open issues: none; Dependabot alerts: none; security advisories: none; fresh-install `npm audit --audit-level=high`: 0 | Ready at local audit time; recheck immediately before release |
| Migration, versioning, security, and contribution policy | `VERSIONING.md`, `ADOPTION_MATRIX.md`, `SECURITY.md`, `CONTRIBUTING.md`, PR and issue templates | Ready |
| External consumers | xue minor upgrade, Fig registry trial, CRUD candidate tarball trial; content/navigation and form/CRUD profiles covered | Ready |
| npm publication, provenance, post-publish smoke | [`RELEASE_RUNBOOK.md`](./RELEASE_RUNBOOK.md), local `.github/workflows/release.yml`, OIDC configuration, and `scripts/registry-smoke.mjs --require-provenance`; the enforced mode correctly rejects current `@webaseui/core@0.1.0` for missing SLSA provenance. GitHub API returns 404 for `release.yml` on `origin/main`, local `npm whoami` is unauthenticated, and latest packages expose npm signatures but no release provenance attestation | Pending publishing the workflow, repository/npm trust setup, and one live release |
| npm publication, provenance, post-publish smoke | GitHub Actions release run `32084203280` published `@webaseui/core@0.2.0-next.0` and `@webaseui/svelte@0.4.0-next.0` through npm trusted publishing. Both packages expose the SLSA v1 provenance predicate, and the provenance-aware registry consumer smoke passed after npm completed dist-tag propagation. `scripts/registry-smoke.mjs` now retries bounded dist-tag and attestation lookups for that propagation window. | Passed for the `next` candidate; verify `latest` again after stable release |

## Required final sign-off

The repository is not a 1.0 RC until the two pending rows have direct evidence:
The repository is not a 1.0 RC until the pending manual audit row has direct evidence:

1. Attach completed VoiceOver/Safari and NVDA/Firefox or Chrome records. The
audit cannot be inferred from axe or browser snapshots.
2. Configure npm trusted publishing for both packages and the exact release
workflow, run one candidate or stable publication from GitHub Actions, verify
provenance, and let the post-publish registry smoke pass.

The current public registry smoke is still useful: it proves the existing
`latest` packages install without workspace links. It is not provenance proof
for the next release and does not close the live publication row.
The publishing gate now has direct candidate evidence. Stable release PR #9
remains intentionally open until the screen-reader records are complete. After
that merge, rerun the same provenance and registry consumer checks against
`latest` before declaring the stable release complete.
5 changes: 5 additions & 0 deletions docs/RELEASE_RUNBOOK.md
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,11 @@ npm view @webaseui/svelte@VERSION dist.attestations --json
npm run check:registry -- --tag=latest --require-provenance
```

npm dist-tags and attestations can take a few seconds to become visible after
publication. The registry smoke retries only those metadata lookups up to six
times at five-second intervals. Package installation and consumer compilation
are not retried; a failure there remains an immediate release failure.

The metadata must include:

```json
Expand Down
40 changes: 40 additions & 0 deletions scripts/lib/registry.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
const SLSA_PROVENANCE_PREDICATE = 'https://slsa.dev/provenance/v1';

function defaultSleep(delayMs) {
return new Promise((resolve) => setTimeout(resolve, delayMs));
}

export function parseRegistryVersion(raw, packageSpec) {
const parsed = JSON.parse(raw);
const version = Array.isArray(parsed) ? parsed.at(-1) : parsed;
if (typeof version !== 'string' || version.length === 0) {
throw new Error(`Registry did not return a version for ${packageSpec}`);
}
return version;
}

export function hasSlsaProvenance(raw) {
const parsed = raw ? JSON.parse(raw) : null;
const metadata = Array.isArray(parsed) ? parsed.at(-1) : parsed;
return metadata?.provenance?.predicateType === SLSA_PROVENANCE_PREDICATE;
}

export async function retryRegistryLookup(
lookup,
{ attempts = 6, delayMs = 5_000, sleep = defaultSleep, onRetry = () => {} } = {}
) {
if (!Number.isInteger(attempts) || attempts < 1) throw new Error('Registry retry attempts must be a positive integer.');
if (!Number.isInteger(delayMs) || delayMs < 0) throw new Error('Registry retry delay must be a non-negative integer.');

for (let attempt = 1; attempt <= attempts; attempt += 1) {
try {
return await lookup();
} catch (error) {
if (attempt === attempts) throw error;
onRetry({ attempt, attempts, delayMs, error });
await sleep(delayMs);
}
}

throw new Error('Registry lookup exhausted without returning a result.');
}
46 changes: 28 additions & 18 deletions scripts/registry-smoke.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { cpSync, mkdirSync, mkdtempSync, readFileSync, realpathSync, rmSync, wri
import { spawnSync } from 'node:child_process';
import os from 'node:os';
import path from 'node:path';
import { hasSlsaProvenance, parseRegistryVersion, retryRegistryLookup } from './lib/registry.mjs';

const root = path.resolve(import.meta.dirname, '..');
const fixture = path.join(root, 'examples/webaseui-svelte-consumer');
Expand All @@ -20,31 +21,40 @@ function run(command, args, cwd = root) {
return result.stdout.trim();
}

function registryVersion(packageName) {
const raw = run('npm', ['view', `${packageName}@${tag}`, 'version', '--json']);
const parsed = JSON.parse(raw);
const version = Array.isArray(parsed) ? parsed.at(-1) : parsed;
if (typeof version !== 'string' || version.length === 0) {
throw new Error(`Registry did not return a version for ${packageName}@${tag}`);
}
return version;
function retryNotice(label) {
return ({ attempt, attempts, delayMs, error }) => {
const message = error instanceof Error ? error.message : String(error);
console.warn(`Registry lookup for ${label} failed (${attempt}/${attempts}): ${message}. Retrying in ${delayMs}ms.`);
};
}

function registryHasProvenance(packageName, version) {
const raw = run('npm', ['view', `${packageName}@${version}`, 'dist.attestations', '--json']);
const parsed = raw ? JSON.parse(raw) : null;
const metadata = Array.isArray(parsed) ? parsed.at(-1) : parsed;
return metadata?.provenance?.predicateType === 'https://slsa.dev/provenance/v1';
async function registryVersion(packageName) {
const packageSpec = `${packageName}@${tag}`;
return retryRegistryLookup(
() => parseRegistryVersion(run('npm', ['view', packageSpec, 'version', '--json']), packageSpec),
{ onRetry: retryNotice(packageSpec) }
);
}

async function assertRegistryProvenance(packageName, version) {
const packageSpec = `${packageName}@${version}`;
await retryRegistryLookup(
() => {
const raw = run('npm', ['view', packageSpec, 'dist.attestations', '--json']);
if (!hasSlsaProvenance(raw)) {
throw new Error(`Registry package ${packageSpec} has no SLSA provenance attestation.`);
}
},
{ onRetry: retryNotice(`${packageSpec} provenance`) }
);
}

try {
const coreVersion = registryVersion('@webaseui/core');
const svelteVersion = registryVersion('@webaseui/svelte');
const coreVersion = await registryVersion('@webaseui/core');
const svelteVersion = await registryVersion('@webaseui/svelte');
if (requireProvenance) {
for (const [packageName, version] of [['@webaseui/core', coreVersion], ['@webaseui/svelte', svelteVersion]]) {
if (!registryHasProvenance(packageName, version)) {
throw new Error(`Registry package ${packageName}@${version} has no SLSA provenance attestation.`);
}
await assertRegistryProvenance(packageName, version);
}
console.log(`Registry provenance verified for @webaseui/core@${coreVersion} and @webaseui/svelte@${svelteVersion}.`);
}
Expand Down
43 changes: 43 additions & 0 deletions tests/unit/registry.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
import { describe, expect, it, vi } from 'vitest';
import { hasSlsaProvenance, parseRegistryVersion, retryRegistryLookup } from '../../scripts/lib/registry.mjs';

describe('registry helpers', () => {
it('parses scalar and npm array version responses', () => {
expect(parseRegistryVersion('"0.4.0-next.0"', '@webaseui/svelte@next')).toBe('0.4.0-next.0');
expect(parseRegistryVersion('["0.3.2","0.4.0-next.0"]', '@webaseui/svelte@next')).toBe('0.4.0-next.0');
expect(() => parseRegistryVersion('{}', '@webaseui/svelte@next')).toThrow('@webaseui/svelte@next');
});

it('recognizes only SLSA v1 provenance metadata', () => {
expect(hasSlsaProvenance(JSON.stringify({ provenance: { predicateType: 'https://slsa.dev/provenance/v1' } }))).toBe(true);
expect(hasSlsaProvenance(JSON.stringify({ provenance: { predicateType: 'https://example.com/predicate' } }))).toBe(false);
expect(hasSlsaProvenance('')).toBe(false);
});

it('retries transient registry failures before returning a result', async () => {
const lookup = vi.fn()
.mockRejectedValueOnce(new Error('E404'))
.mockRejectedValueOnce(new Error('E404'))
.mockResolvedValue('0.4.0-next.0');
const sleep = vi.fn().mockResolvedValue(undefined);
const onRetry = vi.fn();

await expect(retryRegistryLookup(lookup, { attempts: 4, delayMs: 10, sleep, onRetry }))
.resolves.toBe('0.4.0-next.0');
expect(lookup).toHaveBeenCalledTimes(3);
expect(sleep).toHaveBeenCalledTimes(2);
expect(sleep).toHaveBeenCalledWith(10);
expect(onRetry).toHaveBeenNthCalledWith(1, expect.objectContaining({ attempt: 1, attempts: 4, delayMs: 10 }));
expect(onRetry).toHaveBeenNthCalledWith(2, expect.objectContaining({ attempt: 2, attempts: 4, delayMs: 10 }));
});

it('fails after the configured retry limit', async () => {
const failure = new Error('registry unavailable');
const lookup = vi.fn().mockRejectedValue(failure);
const sleep = vi.fn().mockResolvedValue(undefined);

await expect(retryRegistryLookup(lookup, { attempts: 3, delayMs: 0, sleep })).rejects.toBe(failure);
expect(lookup).toHaveBeenCalledTimes(3);
expect(sleep).toHaveBeenCalledTimes(2);
});
});