From ce497d2525237c314ab5ef01cc4aad3e148b6096 Mon Sep 17 00:00:00 2001 From: willxue Date: Tue, 18 Aug 2026 12:36:42 +0800 Subject: [PATCH] fix: tolerate npm registry propagation in smoke test --- docs/RC_READINESS.md | 16 ++++++------- docs/RELEASE_RUNBOOK.md | 5 ++++ scripts/lib/registry.mjs | 40 ++++++++++++++++++++++++++++++++ scripts/registry-smoke.mjs | 46 ++++++++++++++++++++++--------------- tests/unit/registry.test.ts | 43 ++++++++++++++++++++++++++++++++++ 5 files changed, 123 insertions(+), 27 deletions(-) create mode 100644 scripts/lib/registry.mjs create mode 100644 tests/unit/registry.test.ts diff --git a/docs/RC_READINESS.md b/docs/RC_READINESS.md index 75507cb..a6ba599 100644 --- a/docs/RC_READINESS.md +++ b/docs/RC_READINESS.md @@ -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 | @@ -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. diff --git a/docs/RELEASE_RUNBOOK.md b/docs/RELEASE_RUNBOOK.md index 2e2cca0..caa5a80 100644 --- a/docs/RELEASE_RUNBOOK.md +++ b/docs/RELEASE_RUNBOOK.md @@ -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 diff --git a/scripts/lib/registry.mjs b/scripts/lib/registry.mjs new file mode 100644 index 0000000..a8e5c9b --- /dev/null +++ b/scripts/lib/registry.mjs @@ -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.'); +} diff --git a/scripts/registry-smoke.mjs b/scripts/registry-smoke.mjs index 7e5a2f1..518ea0c 100644 --- a/scripts/registry-smoke.mjs +++ b/scripts/registry-smoke.mjs @@ -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'); @@ -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}.`); } diff --git a/tests/unit/registry.test.ts b/tests/unit/registry.test.ts new file mode 100644 index 0000000..fb63e43 --- /dev/null +++ b/tests/unit/registry.test.ts @@ -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); + }); +});