From 0424d11076c58c3c70481177ce67cd1a0dabf4f4 Mon Sep 17 00:00:00 2001 From: Nicholas Petrasek Date: Wed, 30 Sep 2026 11:17:33 +0200 Subject: [PATCH 1/6] build: harden packed consumer verification --- package.json | 5 +- scripts/package-files.json | 10 + scripts/verify-package.mjs | 558 ++++++++++++++++++++++++++------ scripts/verify-package.test.mjs | 153 +++++++++ 4 files changed, 628 insertions(+), 98 deletions(-) create mode 100644 scripts/package-files.json create mode 100644 scripts/verify-package.test.mjs diff --git a/package.json b/package.json index 5fefc9c..dfcf4b6 100644 --- a/package.json +++ b/package.json @@ -6,7 +6,7 @@ "license": "MIT", "repository": { "type": "git", - "url": "https://github.com/NIPE-Solutions/react-data-inspector" + "url": "git+https://github.com/NIPE-Solutions/react-data-inspector.git" }, "files": [ "dist", @@ -47,8 +47,9 @@ "test": "vitest run", "test:e2e": "playwright test", "test:package": "node scripts/verify-package.mjs", + "test:release-policy": "node --test scripts/*.test.mjs", "benchmark": "tsx benchmarks/run.ts", - "check": "npm run format:check && npm run typecheck && npm test && npm run build && npm run build:website && npm run test:website && npm run test:package", + "check": "npm run format:check && npm run typecheck && npm test && npm run test:release-policy && npm run build && npm run build:website && npm run test:website && npm run test:package", "benchmark:memory": "node --expose-gc --import tsx benchmarks/memory.ts", "format": "prettier --write .", "format:check": "prettier --check .", diff --git a/scripts/package-files.json b/scripts/package-files.json new file mode 100644 index 0000000..b629e3e --- /dev/null +++ b/scripts/package-files.json @@ -0,0 +1,10 @@ +[ + "LICENSE", + "README.md", + "dist/index.cjs", + "dist/index.d.cts", + "dist/index.d.ts", + "dist/index.js", + "dist/styles.css", + "package.json" +] diff --git a/scripts/verify-package.mjs b/scripts/verify-package.mjs index 5b63990..1789ea8 100644 --- a/scripts/verify-package.mjs +++ b/scripts/verify-package.mjs @@ -1,79 +1,385 @@ -import { execFileSync } from 'node:child_process' -import { mkdtempSync, writeFileSync, readFileSync, rmSync } from 'node:fs' -import { tmpdir } from 'node:os' -import { join, resolve } from 'node:path' import assert from 'node:assert/strict' +import { execFile } from 'node:child_process' +import console from 'node:console' +import { createRequire } from 'node:module' +import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import path from 'node:path' +import process from 'node:process' +import { pathToFileURL } from 'node:url' +import { promisify } from 'node:util' import { gzipSync } from 'node:zlib' -const pack = JSON.parse( - execFileSync('npm', ['pack', '--json'], { encoding: 'utf8' }), -)[0] -assert( - pack.files.every((file) => - /^(dist\/[^/]+|README\.md|LICENSE|package\.json)$/.test(file.path), - ), -) -const dir = mkdtempSync(join(tmpdir(), 'rdi-package-')) -try { - writeFileSync( - join(dir, 'package.json'), - JSON.stringify({ private: true, type: 'module' }), - ) - execFileSync( - 'npm', - [ - 'install', - '--ignore-scripts', - '--no-audit', - '--no-fund', - resolve(pack.filename), - 'react@19', - 'react-dom@19', - '@types/react@19', - ], - { cwd: dir, stdio: 'pipe' }, + +const execFileAsync = promisify(execFile) +const repositoryRoot = path.resolve(import.meta.dirname, '..') +const packageName = '@nipe-solutions/react-data-inspector' +const publicRegistry = 'https://registry.npmjs.org/' +const expectedRepository = { + type: 'git', + url: 'git+https://github.com/NIPE-Solutions/react-data-inspector.git', +} +const publicExports = { + '.': { + import: { + types: './dist/index.d.ts', + default: './dist/index.js', + }, + require: { + types: './dist/index.d.cts', + default: './dist/index.cjs', + }, + }, + './styles.css': './dist/styles.css', + './package.json': './package.json', +} +const consumerLanes = [ + { + label: 'React 18', + react: '18.3.1', + reactDom: '18.3.1', + reactTypes: '18.3.31', + reactDomTypes: '18.3.7', + }, + { + label: 'React 19', + react: '19.3.0', + reactDom: '19.3.0', + reactTypes: '19.3.0', + reactDomTypes: '19.3.0', + }, +] +const lifecycleScripts = [ + 'preinstall', + 'install', + 'postinstall', + 'prepublish', + 'prepublishOnly', + 'prepare', +] + +export function validatePackedFiles(actualFiles, expectedFiles) { + const actual = [...actualFiles].sort() + const expected = [...expectedFiles].sort() + const unexpected = actual.filter((file) => !expected.includes(file)) + const missing = expected.filter((file) => !actual.includes(file)) + const messages = [] + + if (unexpected.length > 0) + messages.push(`Unexpected packed files: ${unexpected.join(', ')}`) + if (missing.length > 0) + messages.push(`Missing packed files: ${missing.join(', ')}`) + + assert.deepEqual( + actual, + expected, + messages.join('\n') || 'Packed file inventory differs from its allowlist', + ) +} + +export function sanitizeNpmEnvironment( + environment, + { userConfig, globalConfig, cache }, +) { + const sanitized = Object.fromEntries( + Object.entries(environment).filter(([name]) => { + const normalized = name.toLowerCase() + return ( + !normalized.startsWith('npm_config_') && + normalized !== 'npm_token' && + normalized !== 'node_auth_token' && + normalized !== 'yarn_npm_auth_token' + ) + }), + ) + + return { + ...sanitized, + NPM_CONFIG_USERCONFIG: userConfig, + NPM_CONFIG_GLOBALCONFIG: globalConfig, + NPM_CONFIG_CACHE: cache, + NPM_CONFIG_REGISTRY: publicRegistry, + NPM_CONFIG_IGNORE_SCRIPTS: 'true', + NPM_CONFIG_AUDIT: 'false', + NPM_CONFIG_FUND: 'false', + NPM_CONFIG_UPDATE_NOTIFIER: 'false', + } +} + +export function validatePackageManifest(manifest, expected) { + assert.equal(manifest.name, packageName, 'Unexpected package name') + assert.equal( + manifest.version, + expected.version, + 'Packed package version differs from the repository manifest', + ) + assert.deepEqual( + manifest.repository, + expectedRepository, + 'Unexpected package repository', + ) + assert.deepEqual( + manifest.dependencies ?? {}, + {}, + 'Packed package must have zero runtime dependencies', + ) + assert.deepEqual(manifest.peerDependencies, { + react: '^18.3.0 || ^19.0.0', + 'react-dom': '^18.3.0 || ^19.0.0', + }) + assert.deepEqual(manifest.exports, publicExports) + assert.equal( + manifest.publishConfig?.access, + 'public', + 'Package must enforce public access', + ) + assert.equal( + manifest.publishConfig?.provenance, + true, + 'Package must enable provenance', + ) + assert.equal( + manifest.publishConfig?.tag, + expected.tag, + `Package dist-tag must be ${expected.tag}`, + ) + for (const lifecycle of lifecycleScripts) { + assert.equal( + manifest.scripts?.[lifecycle], + undefined, + `Packed package must not contain a ${lifecycle} lifecycle script`, + ) + } +} + +export function formatCommandError(error) { + if (!(error instanceof Error)) return String(error) + const details = [error.stdout, error.stderr] + .map((output) => String(output ?? '').trim()) + .filter(Boolean) + return [error.message.trim(), ...details].join('\n') +} + +export async function verifyTarballConsumers(tarballPath) { + const temporaryRoot = await mkdtemp( + path.join(tmpdir(), 'react-data-inspector-consumers-'), + ) + const npmPaths = await createNpmPaths(temporaryRoot) + const sourceManifest = JSON.parse( + await readFile(path.join(repositoryRoot, 'package.json'), 'utf8'), ) - writeFileSync( - join(dir, 'check.mjs'), - `import assert from 'node:assert/strict';import {createRequire} from 'node:module';import * as pkg from '@nipe-solutions/react-data-inspector';import {createElement} from 'react';import {renderToString} from 'react-dom/server';const require=createRequire(import.meta.url);const cjs=require('@nipe-solutions/react-data-inspector');assert.equal(typeof cjs.DataInspector,'function');assert.equal(pkg.toJsonPointer(['a/b']),'/a~1b');assert.match(renderToString(createElement(pkg.DataInspector,{value:{x:1}})),/role="tree"/);assert.ok(require.resolve('@nipe-solutions/react-data-inspector/styles.css'));assert.deepEqual(Object.keys(pkg).sort(),['DataInspector','defineInspectorType','formatPath','pathEqual','toJavaScriptPath','toJsonPath','toJsonPointer'].sort());`, + + try { + for (const lane of consumerLanes) { + const consumer = path.join(temporaryRoot, lane.label.replace(' ', '-')) + await mkdir(consumer) + await writeFile( + path.join(consumer, 'package.json'), + `${JSON.stringify({ private: true, type: 'module' }, null, 2)}\n`, + ) + + await runNpm( + [ + 'install', + '--ignore-scripts', + '--no-audit', + '--no-fund', + '--no-package-lock', + '--save-exact', + '--install-strategy=hoisted', + path.resolve(tarballPath), + `react@${lane.react}`, + `react-dom@${lane.reactDom}`, + `@types/react@${lane.reactTypes}`, + `@types/react-dom@${lane.reactDomTypes}`, + 'typescript@6.0.3', + ], + consumer, + npmPaths, + ) + + await verifyModulesAndSsr(consumer) + await verifyTypes(consumer) + await verifyExamples(consumer) + await verifyStylesAndManifest(consumer, sourceManifest) + console.log( + `${lane.label}: ESM, CJS, types, CSS, examples, and SSR passed`, + ) + } + } finally { + await rm(temporaryRoot, { recursive: true, force: true }) + } +} + +export async function verifyPackage() { + const temporaryRoot = await mkdtemp( + path.join(tmpdir(), 'react-data-inspector-package-'), ) - execFileSync(process.execPath, ['check.mjs'], { cwd: dir, stdio: 'inherit' }) - writeFileSync( - join(dir, 'check.ts'), - `import {DataInspector,defineInspectorType,toJsonPointer} from '@nipe-solutions/react-data-inspector';import {createElement} from 'react';class Money {amount=12.99};const type=defineInspectorType({id:'money',matches:(v):v is Money=>v instanceof Money,summary:v=>v.amount.toFixed(2)});createElement(DataInspector,{value:new Money(),types:[type]});toJsonPointer(['x',0]);`, + + try { + const packDirectory = path.join(temporaryRoot, 'pack') + await mkdir(packDirectory) + const npmPaths = await createNpmPaths(temporaryRoot) + const expectedFiles = JSON.parse( + await readFile( + path.join(import.meta.dirname, 'package-files.json'), + 'utf8', + ), + ) + const sourceManifest = JSON.parse( + await readFile(path.join(repositoryRoot, 'package.json'), 'utf8'), + ) + const { stdout } = await runNpm( + ['pack', '--json', '--pack-destination', packDirectory], + repositoryRoot, + npmPaths, + ) + const [pack] = JSON.parse(stdout) + assert.ok(pack, 'npm pack did not report an artifact') + validatePackedFiles( + pack.files.map(({ path: file }) => file), + expectedFiles, + ) + assert.ok(pack.size < 40_000, `Tarball is too large: ${pack.size} bytes`) + + const tarballPath = path.join(packDirectory, pack.filename) + await verifyTarballConsumers(tarballPath) + const esmGzip = gzipSync(await readFile('dist/index.js')).length + assert.ok( + esmGzip < 18_000, + `Runtime gzip ${esmGzip} exceeds 18KB review budget`, + ) + const cssGzip = gzipSync(await readFile('dist/styles.css')).length + assert.ok(cssGzip > 0, 'Published stylesheet must not be empty') + validatePackageManifest(sourceManifest, { + version: sourceManifest.version, + tag: sourceManifest.publishConfig?.tag, + }) + console.log( + `Packed package verified (${pack.entryCount} files, ${pack.size} bytes; ESM ${esmGzip} bytes gzip; CSS ${cssGzip} bytes gzip)`, + ) + } finally { + await rm(temporaryRoot, { recursive: true, force: true }) + } +} + +async function verifyModulesAndSsr(consumer) { + const expectedExports = JSON.stringify( + [ + 'DataInspector', + 'defineInspectorType', + 'formatPath', + 'pathEqual', + 'toJavaScriptPath', + 'toJsonPath', + 'toJsonPointer', + ].sort(), ) - execFileSync( + const esm = await run( process.execPath, [ - resolve('node_modules/typescript/bin/tsc'), - '--noEmit', - '--strict', - '--module', - 'NodeNext', - '--moduleResolution', - 'NodeNext', - '--target', - 'ES2022', - 'check.ts', + '--input-type=module', + '--eval', + ` + import assert from 'node:assert/strict' + import { createElement } from 'react' + import { renderToString } from 'react-dom/server' + import * as api from '${packageName}' + + assert.equal(JSON.stringify(Object.keys(api).sort()), '${expectedExports}') + assert.equal(api.toJsonPointer(['a/b']), '/a~1b') + assert.match( + renderToString(createElement(api.DataInspector, { value: { x: 1 } })), + /role="tree"/, + ) + `, ], - { cwd: dir, stdio: 'inherit' }, + consumer, ) - writeFileSync(join(dir, 'check.cts'), readFileSync(join(dir, 'check.ts'))) - execFileSync( + assert.equal(esm.stderr, '') + + const cjs = await run( process.execPath, [ - resolve('node_modules/typescript/bin/tsc'), - '--noEmit', - '--strict', - '--module', - 'NodeNext', - '--moduleResolution', - 'NodeNext', - '--target', - 'ES2022', - 'check.cts', + '--input-type=commonjs', + '--eval', + ` + const assert = require('node:assert/strict') + const api = require('${packageName}') + assert.equal(JSON.stringify(Object.keys(api).sort()), '${expectedExports}') + assert.equal(api.toJsonPointer(['a/b']), '/a~1b') + `, ], - { cwd: dir, stdio: 'inherit' }, + consumer, ) + assert.equal(cjs.stderr, '') +} + +async function verifyTypes(consumer) { + const source = ` + import { + DataInspector, + defineInspectorType, + toJsonPointer, + type DataInspectorProps, + type DataPath, + type InspectorMessages, + type InspectorType, + type RegisteredInspectorType, + type SearchOptions, + type SerializationResult, + } from '${packageName}' + import { createElement } from 'react' + + class Money { amount = 12.99 } + const definition: InspectorType = { + id: 'money', + matches: (value): value is Money => value instanceof Money, + summary: (value) => value.amount.toFixed(2), + } + const type: RegisteredInspectorType = defineInspectorType(definition) + const path: DataPath = ['invoice', 0] + const search: SearchOptions = { maxResults: 10 } + const result: SerializationResult = { ok: true, text: '{}' } + const messages: Partial = {} + const props: DataInspectorProps = { value: new Money(), types: [type] } + createElement(DataInspector, props) + void toJsonPointer(path) + void search + void result + void messages + ` + await writeFile(path.join(consumer, 'index.ts'), source) + await writeFile(path.join(consumer, 'index.cts'), source) + await writeFile( + path.join(consumer, 'tsconfig.json'), + `${JSON.stringify( + { + compilerOptions: { + target: 'ES2022', + lib: ['ES2022', 'DOM', 'DOM.Iterable'], + module: 'NodeNext', + moduleResolution: 'NodeNext', + strict: true, + exactOptionalPropertyTypes: true, + noUncheckedIndexedAccess: true, + noEmit: true, + skipLibCheck: false, + types: [], + }, + include: ['index.ts', 'index.cts'], + }, + null, + 2, + )}\n`, + ) + await run( + process.execPath, + ['node_modules/typescript/bin/tsc', '--project', 'tsconfig.json'], + consumer, + ) +} + +async function verifyExamples(consumer) { for (const [input, output] of [ ['examples/live-updates.tsx', 'live-updates.tsx'], ['examples/live-data.ts', 'live-data.ts'], @@ -82,42 +388,102 @@ try { ['examples/customization.tsx', 'customization.tsx'], ['website/playground/scenarios.tsx', 'scenarios.tsx'], ]) { - const code = readFileSync(input, 'utf8') - .replaceAll("'../src'", "'@nipe-solutions/react-data-inspector'") - .replaceAll("'../../src'", "'@nipe-solutions/react-data-inspector'") + const code = (await readFile(path.join(repositoryRoot, input), 'utf8')) + .replaceAll("'../src'", `'${packageName}'`) + .replaceAll("'../../src'", `'${packageName}'`) .replaceAll("'../../examples/customization'", "'./customization'") - writeFileSync(join(dir, output), code) + await writeFile(path.join(consumer, output), code) } - execFileSync( + await writeFile( + path.join(consumer, 'tsconfig.examples.json'), + `${JSON.stringify( + { + compilerOptions: { + target: 'ES2022', + lib: ['ES2022', 'DOM', 'DOM.Iterable'], + module: 'ESNext', + moduleResolution: 'Bundler', + jsx: 'react-jsx', + strict: true, + exactOptionalPropertyTypes: true, + noUncheckedIndexedAccess: true, + noEmit: true, + skipLibCheck: false, + types: [], + }, + include: [ + 'live-updates.tsx', + 'stress-data.ts', + 'update-pulse.tsx', + 'customization.tsx', + 'scenarios.tsx', + ], + }, + null, + 2, + )}\n`, + ) + await run( process.execPath, - [ - resolve('node_modules/typescript/bin/tsc'), - '--noEmit', - '--strict', - '--noUncheckedIndexedAccess', - '--exactOptionalPropertyTypes', - '--jsx', - 'react-jsx', - '--target', - 'ES2022', - '--module', - 'ESNext', - '--moduleResolution', - 'Bundler', - 'live-updates.tsx', - 'stress-data.ts', - 'update-pulse.tsx', - 'customization.tsx', - 'scenarios.tsx', - ], - { cwd: dir, stdio: 'inherit' }, + ['node_modules/typescript/bin/tsc', '--project', 'tsconfig.examples.json'], + consumer, ) - const gzip = gzipSync(readFileSync('dist/index.js')).length - assert(gzip < 18000, `Runtime gzip ${gzip} exceeds 18KB review budget`) - console.log( - `Tarball consumer imports and SSR passed; ESM gzip ${gzip} bytes; CSS ${gzipSync(readFileSync('dist/styles.css')).length} bytes gzip.`, +} + +async function verifyStylesAndManifest(consumer, sourceManifest) { + const require = createRequire(path.join(consumer, 'package.json')) + const stylesheetPath = require.resolve(`${packageName}/styles.css`) + assert.ok( + (await readFile(stylesheetPath, 'utf8')).trim().length > 0, + 'styles.css must contain CSS', ) -} finally { - rmSync(dir, { recursive: true, force: true }) - rmSync(pack.filename) + const manifest = JSON.parse( + await readFile(require.resolve(`${packageName}/package.json`), 'utf8'), + ) + validatePackageManifest(manifest, { + version: sourceManifest.version, + tag: sourceManifest.publishConfig?.tag, + }) +} + +async function createNpmPaths(temporaryRoot) { + const paths = { + userConfig: path.join(temporaryRoot, 'user.npmrc'), + globalConfig: path.join(temporaryRoot, 'global.npmrc'), + cache: path.join(temporaryRoot, 'npm-cache'), + } + await Promise.all([ + writeFile(paths.userConfig, ''), + writeFile(paths.globalConfig, ''), + mkdir(paths.cache), + ]) + return paths +} + +function runNpm(args, cwd, npmPaths) { + return execFileAsync('npm', args, { + cwd, + env: sanitizeNpmEnvironment(process.env, npmPaths), + maxBuffer: 10 * 1024 * 1024, + }) +} + +function run(command, args, cwd = repositoryRoot) { + return execFileAsync(command, args, { + cwd, + maxBuffer: 10 * 1024 * 1024, + }) +} + +const isMain = + process.argv[1] && + pathToFileURL(path.resolve(process.argv[1])).href === import.meta.url + +if (isMain) { + try { + await verifyPackage() + } catch (error) { + console.error(formatCommandError(error)) + process.exitCode = 1 + } } diff --git a/scripts/verify-package.test.mjs b/scripts/verify-package.test.mjs new file mode 100644 index 0000000..255ba8f --- /dev/null +++ b/scripts/verify-package.test.mjs @@ -0,0 +1,153 @@ +import assert from 'node:assert/strict' +import { readFile } from 'node:fs/promises' +import path from 'node:path' +import test from 'node:test' +import { + formatCommandError, + sanitizeNpmEnvironment, + validatePackageManifest, + validatePackedFiles, +} from './verify-package.mjs' + +const repositoryRoot = path.resolve(import.meta.dirname, '..') +const expectedFiles = JSON.parse( + await readFile( + path.join(repositoryRoot, 'scripts/package-files.json'), + 'utf8', + ), +) + +const stableManifest = { + name: '@nipe-solutions/react-data-inspector', + version: '1.0.0', + repository: { + type: 'git', + url: 'git+https://github.com/NIPE-Solutions/react-data-inspector.git', + }, + dependencies: {}, + peerDependencies: { + react: '^18.3.0 || ^19.0.0', + 'react-dom': '^18.3.0 || ^19.0.0', + }, + publishConfig: { access: 'public', provenance: true, tag: 'latest' }, + exports: { + '.': { + import: { + types: './dist/index.d.ts', + default: './dist/index.js', + }, + require: { + types: './dist/index.d.cts', + default: './dist/index.cjs', + }, + }, + './styles.css': './dist/styles.css', + './package.json': './package.json', + }, + scripts: { test: 'vitest run' }, +} + +test('packed inventory rejects missing and private files', () => { + assert.doesNotThrow(() => validatePackedFiles(expectedFiles, expectedFiles)) + assert.throws( + () => + validatePackedFiles([...expectedFiles, 'src/private.ts'], expectedFiles), + /Unexpected packed files: src\/private\.ts/, + ) + assert.throws( + () => validatePackedFiles(expectedFiles.slice(1), expectedFiles), + /Missing packed files: LICENSE/, + ) +}) + +test('npm environment uses isolated configuration without credentials', () => { + const result = sanitizeNpmEnvironment( + { + PATH: '/usr/bin', + HTTPS_PROXY: 'http://proxy.invalid', + NODE_EXTRA_CA_CERTS: '/tmp/ca.pem', + npm_config_allow_scripts: '@example/lower', + NPM_CONFIG_ALLOW_SCRIPTS: '@example/upper', + NPM_CONFIG_USERCONFIG: '/home/person/.npmrc', + npm_config_globalconfig: '/etc/npmrc', + NPM_CONFIG_CACHE: '/home/person/.npm', + npm_config_registry: 'https://registry.example.invalid', + 'npm_config_//registry.npmjs.org/:_authToken': 'secret', + NPM_TOKEN: 'secret', + NODE_AUTH_TOKEN: 'secret', + }, + { + userConfig: '/tmp/isolated-user.npmrc', + globalConfig: '/tmp/isolated-global.npmrc', + cache: '/tmp/isolated-cache', + }, + ) + + assert.equal(result.PATH, '/usr/bin') + assert.equal(result.HTTPS_PROXY, 'http://proxy.invalid') + assert.equal(result.NODE_EXTRA_CA_CERTS, '/tmp/ca.pem') + assert.equal(result.npm_config_allow_scripts, undefined) + assert.equal(result.NPM_CONFIG_ALLOW_SCRIPTS, undefined) + assert.equal(result.NPM_TOKEN, undefined) + assert.equal(result.NODE_AUTH_TOKEN, undefined) + assert.equal(result['npm_config_//registry.npmjs.org/:_authToken'], undefined) + assert.equal(result.NPM_CONFIG_USERCONFIG, '/tmp/isolated-user.npmrc') + assert.equal(result.NPM_CONFIG_GLOBALCONFIG, '/tmp/isolated-global.npmrc') + assert.equal(result.NPM_CONFIG_CACHE, '/tmp/isolated-cache') + assert.equal(result.NPM_CONFIG_REGISTRY, 'https://registry.npmjs.org/') + assert.equal(result.NPM_CONFIG_IGNORE_SCRIPTS, 'true') + assert.equal(result.NPM_CONFIG_AUDIT, 'false') + assert.equal(result.NPM_CONFIG_FUND, 'false') + assert.equal(result.NPM_CONFIG_UPDATE_NOTIFIER, 'false') +}) + +test('stable package manifest exposes only the supported consumer contract', () => { + assert.doesNotThrow(() => + validatePackageManifest(stableManifest, { + version: '1.0.0', + tag: 'latest', + }), + ) + assert.throws( + () => + validatePackageManifest( + { + ...stableManifest, + publishConfig: { ...stableManifest.publishConfig, tag: 'beta' }, + }, + { version: '1.0.0', tag: 'latest' }, + ), + /dist-tag/, + ) + assert.throws( + () => + validatePackageManifest( + { ...stableManifest, dependencies: { utility: '1.0.0' } }, + { version: '1.0.0', tag: 'latest' }, + ), + /runtime dependencies/, + ) + assert.throws( + () => + validatePackageManifest( + { + ...stableManifest, + scripts: { ...stableManifest.scripts, postinstall: 'node setup.mjs' }, + }, + { version: '1.0.0', tag: 'latest' }, + ), + /postinstall lifecycle script/, + ) +}) + +test('command failures include captured compiler diagnostics', () => { + const error = Object.assign(new Error('Command failed: tsc'), { + stdout: 'index.ts(1,1): error TS1000: Invalid fixture\n', + stderr: '', + }) + + assert.equal( + formatCommandError(error), + 'Command failed: tsc\nindex.ts(1,1): error TS1000: Invalid fixture', + ) +}) From 00de0b1082a4b75b7e4427e1b8cd1846da6fd1e9 Mon Sep 17 00:00:00 2001 From: Nicholas Petrasek Date: Wed, 30 Sep 2026 11:23:05 +0200 Subject: [PATCH 2/6] build: verify stable release artifacts --- package.json | 3 +- scripts/validate-release.mjs | 397 +++++++++++++++++++++++++++--- scripts/validate-release.test.mjs | 276 +++++++++++++++++++++ 3 files changed, 647 insertions(+), 29 deletions(-) create mode 100644 scripts/validate-release.test.mjs diff --git a/package.json b/package.json index dfcf4b6..2399149 100644 --- a/package.json +++ b/package.json @@ -56,7 +56,8 @@ "benchmark:browser": "node benchmarks/browser.mjs", "build:website:profile": "vite build --config website/vite.config.ts --mode profiling", "preview:website": "vite preview --config website/vite.config.ts --host 127.0.0.1 --port 5173 --strictPort", - "test:website": "node scripts/verify-website.mjs" + "test:website": "node scripts/verify-website.mjs", + "release:check": "node scripts/validate-release.mjs" }, "devDependencies": { "@axe-core/playwright": "^4.13.0", diff --git a/scripts/validate-release.mjs b/scripts/validate-release.mjs index ead0f68..49c60e8 100644 --- a/scripts/validate-release.mjs +++ b/scripts/validate-release.mjs @@ -1,28 +1,369 @@ -import { readFile } from 'node:fs/promises' -import { execFileSync } from 'node:child_process' -const pkg = JSON.parse(await readFile('package.json', 'utf8')) -const lock = JSON.parse(await readFile('package-lock.json', 'utf8')) -if ( - process.env.GITHUB_REF_TYPE !== 'tag' || - process.env.GITHUB_REF_NAME !== `v${pkg.version}` -) - throw Error('Release tag must exactly match the package version') -if ( - !/^\d+\.\d+\.\d+-beta\.\d+$/.test(pkg.version) || - pkg.publishConfig.tag !== 'beta' -) - throw Error('This release workflow publishes beta versions only') -if (lock.version !== pkg.version || lock.packages[''].version !== pkg.version) - throw Error('Package lock version does not match') -execFileSync('git', ['merge-base', '--is-ancestor', 'HEAD', 'origin/main']) -const response = await fetch( - `https://registry.npmjs.org/${encodeURIComponent(pkg.name)}/${pkg.version}`, - { signal: AbortSignal.timeout(20000) }, -) -if (response.status !== 404) - throw Error( - response.ok - ? 'This version already exists; bump the version for a new release' - : `Registry verification failed: ${response.status}`, - ) -console.log(`Verified release ${pkg.name}@${pkg.version} from main`) +import assert from 'node:assert/strict' +import { execFile } from 'node:child_process' +import console from 'node:console' +import { createHash } from 'node:crypto' +import { + appendFile, + mkdir, + mkdtemp, + readFile, + rm, + writeFile, +} from 'node:fs/promises' +import { tmpdir } from 'node:os' +import path from 'node:path' +import process from 'node:process' +import { pathToFileURL } from 'node:url' +import { promisify } from 'node:util' +import { + formatCommandError, + sanitizeNpmEnvironment, + validatePackedFiles, + verifyTarballConsumers, +} from './verify-package.mjs' + +const execFileAsync = promisify(execFile) +const repositoryRoot = path.resolve(import.meta.dirname, '..') +const expectedName = '@nipe-solutions/react-data-inspector' +const expectedRepository = { + type: 'git', + url: 'git+https://github.com/NIPE-Solutions/react-data-inspector.git', +} +const stableSemver = /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)$/ + +export function validateReleaseMetadata(packageJson, packageLock, changelog) { + assert.equal(packageJson.name, expectedName, 'Unexpected package name') + assert.deepEqual( + packageJson.repository, + expectedRepository, + 'Unexpected package repository', + ) + assert.match( + packageJson.version, + stableSemver, + `Package version ${packageJson.version} must be a stable semantic version`, + ) + assert.match( + changelog, + new RegExp( + `^## ${escapeRegExp(packageJson.version)} — \\d{4}-\\d{2}-\\d{2}$`, + 'm', + ), + `CHANGELOG.md must contain a dated release entry for ${packageJson.version}`, + ) + assert.equal( + packageJson.publishConfig?.access, + 'public', + 'publishConfig must enforce public access', + ) + assert.equal( + packageJson.publishConfig?.provenance, + true, + 'publishConfig must enable provenance', + ) + assert.equal( + packageJson.publishConfig?.tag, + 'latest', + 'Stable releases must use the latest dist-tag', + ) + assert.deepEqual( + packageJson.dependencies ?? {}, + {}, + 'Stable releases must have zero runtime dependencies', + ) + assert.equal( + packageLock.version, + packageJson.version, + 'Package lock version does not match the release version', + ) + assert.equal( + packageLock.packages?.['']?.version, + packageJson.version, + 'Package lock root version does not match the release version', + ) + + return { + name: packageJson.name, + version: packageJson.version, + channel: 'latest', + } +} + +export function validateRepositoryContext({ + branch, + dirtyEntries, + dryRun, + githubActions, + eventName, + refName, + refType, + version, +}) { + if (githubActions) { + assert.equal( + dirtyEntries.length, + 0, + 'GitHub release tracked state must be clean', + ) + assert.equal( + eventName, + 'release', + 'GitHub verification requires a release event', + ) + assert.equal(refType, 'tag', 'GitHub release verification requires a tag') + assert.equal( + refName, + `v${version}`, + `Release tag ${refName} must exactly match v${version}`, + ) + return [] + } + + assert.equal(dryRun, true, 'Local release verification is dry-run only') + const messages = [] + if (dirtyEntries.length > 0) { + const noun = dirtyEntries.length === 1 ? 'change' : 'changes' + messages.push( + `Local dry-run: allowing ${dirtyEntries.length} tracked working-tree ${noun} for inspection`, + ) + } + if (branch && branch !== 'main') + messages.push(`Local dry-run: running from feature branch ${branch}`) + return messages +} + +export function assertRegistryVersionAbsent(publishedVersion, version) { + assert.notEqual( + publishedVersion, + version, + `${expectedName}@${version} already exists on the npm registry`, + ) +} + +export function parseArguments(args) { + const usage = 'Usage: validate-release.mjs --dry-run [--output directory]' + assert.equal(args[0], '--dry-run', usage) + if (args.length === 1) return { dryRun: true, outputDirectory: undefined } + assert.equal(args.length, 3, usage) + assert.equal(args[1], '--output', usage) + assert.ok(args[2], usage) + return { dryRun: true, outputDirectory: args[2] } +} + +export async function createArtifactManifest(pack, tarballPath, release) { + assert.equal( + pack.filename, + path.basename(tarballPath), + 'Pack metadata filename does not match the release tarball', + ) + const digest = createHash('sha512') + .update(await readFile(tarballPath)) + .digest() + return { + name: release.name, + version: release.version, + channel: release.channel, + filename: pack.filename, + integrity: `sha512-${digest.toString('base64')}`, + sha512: digest.toString('hex'), + files: pack.files.map(({ path: file }) => file).sort(), + } +} + +export async function verifyRelease({ dryRun = false, outputDirectory } = {}) { + assert.equal(dryRun, true, 'Release verification requires --dry-run') + + const [packageSource, lockSource, changelog, expectedFilesSource] = + await Promise.all([ + readFile(path.join(repositoryRoot, 'package.json'), 'utf8'), + readFile(path.join(repositoryRoot, 'package-lock.json'), 'utf8'), + readFile(path.join(repositoryRoot, 'CHANGELOG.md'), 'utf8'), + readFile(path.join(import.meta.dirname, 'package-files.json'), 'utf8'), + ]) + const packageJson = JSON.parse(packageSource) + const packageLock = JSON.parse(lockSource) + const expectedFiles = JSON.parse(expectedFilesSource) + const release = validateReleaseMetadata(packageJson, packageLock, changelog) + + assert.match( + packageJson.scripts?.check ?? '', + /npm run test:release-policy/, + 'The quality gate must include release policy tests', + ) + assert.doesNotMatch( + packageJson.scripts?.check ?? '', + /release:check/, + 'The normal quality gate must not invoke release verification', + ) + + const [{ stdout: status }, { stdout: branch }] = await Promise.all([ + run('git', ['status', '--porcelain', '--untracked-files=no']), + run('git', ['branch', '--show-current']), + ]) + const dirtyEntries = status.trim() ? status.trimEnd().split('\n') : [] + for (const message of validateRepositoryContext({ + branch: branch.trim(), + dirtyEntries, + dryRun, + githubActions: process.env.GITHUB_ACTIONS === 'true', + eventName: process.env.GITHUB_EVENT_NAME, + refName: process.env.GITHUB_REF_NAME, + refType: process.env.GITHUB_REF_TYPE, + version: release.version, + })) { + console.log(message) + } + + const workingRoot = await mkdtemp( + path.join(tmpdir(), 'react-data-inspector-release-'), + ) + const packDirectory = outputDirectory + ? path.resolve(repositoryRoot, outputDirectory) + : path.join(workingRoot, 'artifact') + + try { + const npmPaths = await createNpmPaths(workingRoot) + await mkdir(packDirectory) + const { stdout } = await runNpm( + ['pack', '--json', '--pack-destination', packDirectory], + repositoryRoot, + npmPaths, + ) + const [pack] = JSON.parse(stdout) + assert.ok(pack, 'npm pack did not report an artifact') + + const expectedFilename = + `${release.name.replace(/^@/, '').replaceAll('/', '-')}-` + + `${release.version}.tgz` + assert.equal( + pack.filename, + expectedFilename, + 'npm pack returned an unexpected artifact filename', + ) + validatePackedFiles( + pack.files.map(({ path: file }) => file), + expectedFiles, + ) + assert.ok( + pack.size < 40_000, + `Compressed package must be below 40,000 bytes: ${pack.size}`, + ) + + const tarballPath = path.join(packDirectory, pack.filename) + await verifyTarballConsumers(tarballPath) + const publishedVersion = await readRegistryVersion( + release.name, + release.version, + npmPaths, + ) + assertRegistryVersionAbsent(publishedVersion, release.version) + + await runVisibleNpm( + [ + 'publish', + '--dry-run', + '--ignore-scripts', + '--provenance', + '--access', + 'public', + '--tag', + release.channel, + tarballPath, + ], + repositoryRoot, + npmPaths, + ) + + const manifest = await createArtifactManifest(pack, tarballPath, release) + const manifestPath = path.join(packDirectory, 'release-manifest.json') + await writeFile(manifestPath, `${JSON.stringify(manifest, null, 2)}\n`, { + flag: 'wx', + }) + if (outputDirectory) { + await writeGitHubOutputs({ tarball: pack.filename }) + console.log(`Verified artifact preserved at ${tarballPath}`) + } + console.log( + `Release dry-run passed for ${release.name}@${release.version}; nothing was published`, + ) + return manifest + } finally { + await rm(workingRoot, { recursive: true, force: true }) + } +} + +async function createNpmPaths(temporaryRoot) { + const paths = { + userConfig: path.join(temporaryRoot, 'user.npmrc'), + globalConfig: path.join(temporaryRoot, 'global.npmrc'), + cache: path.join(temporaryRoot, 'npm-cache'), + } + await Promise.all([ + writeFile(paths.userConfig, ''), + writeFile(paths.globalConfig, ''), + mkdir(paths.cache), + ]) + return paths +} + +async function readRegistryVersion(name, version, npmPaths) { + try { + const { stdout } = await runNpm( + ['view', `${name}@${version}`, 'version', '--json'], + repositoryRoot, + npmPaths, + ) + const parsed = JSON.parse(stdout) + return typeof parsed === 'string' ? parsed : undefined + } catch (error) { + const output = `${String(error.stdout ?? '')}\n${String(error.stderr ?? '')}` + if (/E404|404 Not Found/.test(output)) return undefined + throw error + } +} + +async function writeGitHubOutputs(outputs) { + if (!process.env.GITHUB_OUTPUT) return + const lines = Object.entries(outputs) + .map(([name, value]) => `${name}=${value}`) + .join('\n') + await appendFile(process.env.GITHUB_OUTPUT, `${lines}\n`) +} + +async function runVisibleNpm(args, cwd, npmPaths) { + const { stdout, stderr } = await runNpm(args, cwd, npmPaths) + if (stdout) process.stdout.write(stdout) + if (stderr) process.stderr.write(stderr) +} + +function runNpm(args, cwd, npmPaths) { + return execFileAsync('npm', args, { + cwd, + env: sanitizeNpmEnvironment(process.env, npmPaths), + maxBuffer: 10 * 1024 * 1024, + }) +} + +function run(command, args) { + return execFileAsync(command, args, { + cwd: repositoryRoot, + maxBuffer: 10 * 1024 * 1024, + }) +} + +function escapeRegExp(value) { + return value.replaceAll(/[.*+?^${}()|[\]\\]/g, '\\$&') +} + +const isMain = + process.argv[1] && + pathToFileURL(path.resolve(process.argv[1])).href === import.meta.url + +if (isMain) { + try { + await verifyRelease(parseArguments(process.argv.slice(2))) + } catch (error) { + console.error(formatCommandError(error)) + process.exitCode = 1 + } +} diff --git a/scripts/validate-release.test.mjs b/scripts/validate-release.test.mjs new file mode 100644 index 0000000..70094fc --- /dev/null +++ b/scripts/validate-release.test.mjs @@ -0,0 +1,276 @@ +import assert from 'node:assert/strict' +import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import path from 'node:path' +import test from 'node:test' +import { + assertRegistryVersionAbsent, + createArtifactManifest, + parseArguments, + validateReleaseMetadata, + validateRepositoryContext, +} from './validate-release.mjs' + +const repositoryRoot = path.resolve(import.meta.dirname, '..') +const expectedFiles = JSON.parse( + await readFile( + path.join(repositoryRoot, 'scripts/package-files.json'), + 'utf8', + ), +) +const stablePackage = { + name: '@nipe-solutions/react-data-inspector', + version: '1.0.0', + repository: { + type: 'git', + url: 'git+https://github.com/NIPE-Solutions/react-data-inspector.git', + }, + dependencies: {}, + publishConfig: { access: 'public', provenance: true, tag: 'latest' }, +} +const stableLock = { + version: '1.0.0', + packages: { '': { version: '1.0.0' } }, +} +const stableChangelog = '## 1.0.0 — 2026-09-30\n' + +test('stable metadata selects the latest channel', () => { + assert.deepEqual( + validateReleaseMetadata(stablePackage, stableLock, stableChangelog), + { + name: stablePackage.name, + version: '1.0.0', + channel: 'latest', + }, + ) +}) + +test('stable metadata rejects invalid versions and release policy', () => { + for (const version of ['1.0.0-beta.1', 'v1.0.0', '01.0.0', '1.0']) { + assert.throws( + () => + validateReleaseMetadata( + { ...stablePackage, version }, + { + version, + packages: { '': { version } }, + }, + `## ${version} — 2026-09-30\n`, + ), + /stable semantic version/, + ) + } + + assert.throws( + () => + validateReleaseMetadata( + { + ...stablePackage, + repository: { type: 'git', url: 'https://example.invalid/repo' }, + }, + stableLock, + stableChangelog, + ), + /repository/, + ) + assert.throws( + () => + validateReleaseMetadata( + { + ...stablePackage, + publishConfig: { ...stablePackage.publishConfig, tag: 'beta' }, + }, + stableLock, + stableChangelog, + ), + /latest dist-tag/, + ) + assert.throws( + () => + validateReleaseMetadata( + { + ...stablePackage, + publishConfig: { + ...stablePackage.publishConfig, + access: 'restricted', + }, + }, + stableLock, + stableChangelog, + ), + /public access/, + ) + assert.throws( + () => + validateReleaseMetadata( + { + ...stablePackage, + publishConfig: { ...stablePackage.publishConfig, provenance: false }, + }, + stableLock, + stableChangelog, + ), + /provenance/, + ) + assert.throws( + () => + validateReleaseMetadata( + { ...stablePackage, dependencies: { utility: '1.0.0' } }, + stableLock, + stableChangelog, + ), + /runtime dependencies/, + ) + assert.throws( + () => + validateReleaseMetadata( + stablePackage, + { ...stableLock, version: '0.9.0' }, + stableChangelog, + ), + /lock version/, + ) + assert.throws( + () => validateReleaseMetadata(stablePackage, stableLock, '## Unreleased\n'), + /dated release entry/, + ) +}) + +test('GitHub release context requires a clean exact version tag', () => { + const context = { + branch: '', + dirtyEntries: [], + dryRun: true, + githubActions: true, + eventName: 'release', + refName: 'v1.0.0', + refType: 'tag', + version: '1.0.0', + } + assert.deepEqual(validateRepositoryContext(context), []) + assert.throws( + () => + validateRepositoryContext({ + ...context, + dirtyEntries: [' M package.json'], + }), + /tracked state must be clean/, + ) + assert.throws( + () => validateRepositoryContext({ ...context, eventName: 'push' }), + /release event/, + ) + assert.throws( + () => validateRepositoryContext({ ...context, refType: 'branch' }), + /requires a tag/, + ) + assert.throws( + () => validateRepositoryContext({ ...context, refName: 'v1.0.1' }), + /must exactly match v1\.0\.0/, + ) +}) + +test('local release inspection is dry-run only and reports context', () => { + assert.deepEqual( + validateRepositoryContext({ + branch: 'release/1.0.0', + dirtyEntries: [' M package.json'], + dryRun: true, + githubActions: false, + eventName: undefined, + refName: undefined, + refType: undefined, + version: '1.0.0', + }), + [ + 'Local dry-run: allowing 1 tracked working-tree change for inspection', + 'Local dry-run: running from feature branch release/1.0.0', + ], + ) + assert.throws( + () => + validateRepositoryContext({ + branch: 'main', + dirtyEntries: [], + dryRun: false, + githubActions: false, + eventName: undefined, + refName: undefined, + refType: undefined, + version: '1.0.0', + }), + /dry-run only/, + ) +}) + +test('release arguments accept only documented dry-run forms', () => { + assert.deepEqual(parseArguments(['--dry-run']), { + dryRun: true, + outputDirectory: undefined, + }) + assert.deepEqual(parseArguments(['--dry-run', '--output', 'artifact']), { + dryRun: true, + outputDirectory: 'artifact', + }) + for (const args of [ + [], + ['--publish'], + ['--dry-run', '--output'], + ['--dry-run', '--output', 'artifact', '--extra'], + ]) { + assert.throws(() => parseArguments(args), /Usage:/) + } +}) + +test('registry guard rejects an existing immutable version', () => { + assert.doesNotThrow(() => assertRegistryVersionAbsent(undefined, '1.0.0')) + assert.throws( + () => assertRegistryVersionAbsent('1.0.0', '1.0.0'), + /already exists/, + ) +}) + +test('artifact manifest binds release metadata and inventory to the tarball', async () => { + const directory = await mkdtemp(path.join(tmpdir(), 'rdi-release-policy-')) + const tarball = path.join( + directory, + 'nipe-solutions-react-data-inspector-1.0.0.tgz', + ) + try { + await writeFile(tarball, 'verified artifact\n') + const manifest = await createArtifactManifest( + { + filename: path.basename(tarball), + files: expectedFiles.map((file) => ({ path: file })), + }, + tarball, + { name: stablePackage.name, version: '1.0.0', channel: 'latest' }, + ) + + assert.deepEqual(manifest, { + name: stablePackage.name, + version: '1.0.0', + channel: 'latest', + filename: path.basename(tarball), + integrity: + 'sha512-12QB5Gs/CHundUNneURSBjHM2QunHFMmpAZQaCVZTsZ5EH6RUFU3BMA3YGiiv3kT8bWYmbh7nbNVBH8kaThyVQ==', + sha512: + 'd76401e46b3f087ba77543677944520631ccd90ba71c5326a406506825594ec679107e9150553704c0376068a2bf7913f1b59899b87b9db355047f2469387255', + files: expectedFiles, + }) + + await writeFile(tarball, 'changed artifact\n') + const changed = await createArtifactManifest( + { + filename: path.basename(tarball), + files: expectedFiles.map((file) => ({ path: file })), + }, + tarball, + { name: stablePackage.name, version: '1.0.0', channel: 'latest' }, + ) + assert.notEqual(changed.sha512, manifest.sha512) + assert.notEqual(changed.integrity, manifest.integrity) + } finally { + await rm(directory, { recursive: true, force: true }) + } +}) From e0f401506f70fb31d0799129c2a52b57095bad8d Mon Sep 17 00:00:00 2001 From: Nicholas Petrasek Date: Wed, 30 Sep 2026 11:31:31 +0200 Subject: [PATCH 3/6] ci: protect stable artifact publication --- .github/workflows/ci.yml | 101 +++---- .github/workflows/release.yml | 186 +++++++++++++ package-lock.json | 19 +- package.json | 3 +- scripts/verify-workflows.test.mjs | 433 ++++++++++++++++++++++++++++++ 5 files changed, 690 insertions(+), 52 deletions(-) create mode 100644 .github/workflows/release.yml create mode 100644 scripts/verify-workflows.test.mjs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 4396b56..b17dcf0 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,95 +1,96 @@ name: Verify -on: [push, pull_request, workflow_dispatch] + +on: + push: + pull_request: + workflow_dispatch: + concurrency: - group: verify-${{ github.workflow }}-${{ github.ref }} - cancel-in-progress: true + group: react-data-inspector-ci-${{ github.ref }} + cancel-in-progress: false + permissions: contents: read + jobs: test: - runs-on: ubuntu-latest + runs-on: ubuntu-24.04 + timeout-minutes: 60 strategy: + fail-fast: false matrix: - react: ['18.3.1', '19'] + react: ['18.3.1', '19.3.0'] steps: - - uses: actions/checkout@v4 - - uses: actions/setup-node@v4 + - name: Check out repository + uses: actions/checkout@v7 + - name: Set up Node.js + uses: actions/setup-node@v7 with: node-version: 24 cache: npm - - run: npm ci - - run: npm install --no-save react@${{ matrix.react }} react-dom@${{ matrix.react }} - - run: npm run check - - run: npx playwright install --with-deps chromium firefox webkit - - run: npm run test:e2e - - run: npm run benchmark - - uses: actions/upload-artifact@v4 - if: always() - # Diagnostic uploads must not fail verification when org storage is full. + cache-dependency-path: package-lock.json + - name: Install dependencies + run: npm ci + - name: Select React version + run: npm install --no-save --ignore-scripts react@${{ matrix.react }} react-dom@${{ matrix.react }} + - name: Run quality gate + run: npm run check + - name: Install browser engines + run: npx playwright install --with-deps chromium firefox webkit + - name: Run browser suite + run: npm run test:e2e + - name: Run model benchmarks + run: npm run benchmark + - name: Preserve failure evidence + if: failure() continue-on-error: true + uses: actions/upload-artifact@v7 with: name: evidence-react-${{ matrix.react }} + if-no-files-found: ignore retention-days: 7 path: | test-results/ + playwright-report/ benchmarks/results.json verify: name: Verified if: always() needs: test - runs-on: ubuntu-latest + runs-on: ubuntu-24.04 steps: - name: Require every React version to pass env: RESULT: ${{ needs.test.result }} run: test "$RESULT" = success - publish: - name: Publish beta package - needs: verify - if: github.ref_type == 'tag' && startsWith(github.ref_name, 'v') - runs-on: ubuntu-latest - environment: npm - permissions: - contents: read - id-token: write - steps: - - uses: actions/checkout@v4 - with: - fetch-depth: 0 - - uses: actions/setup-node@v4 - with: - node-version: 24 - registry-url: https://registry.npmjs.org - - run: npm ci - - run: node scripts/validate-release.mjs - - run: npm run build - - run: npm run test:package - - name: Publish verified beta with provenance - run: npm publish --tag beta --access public --provenance - deploy: name: Deploy website - needs: [verify, publish] - if: always() && needs.verify.result == 'success' && github.event_name != 'pull_request' && ((github.ref_type == 'tag' && needs.publish.result == 'success') || (github.ref_type != 'tag' && needs.publish.result == 'skipped')) - runs-on: ubuntu-latest + needs: verify + if: "needs.verify.result == 'success' && github.event_name != 'pull_request' && github.ref_type != 'tag'" + runs-on: ubuntu-24.04 environment: - name: ${{ (github.ref == 'refs/heads/main' || github.ref_type == 'tag') && 'production' || 'preview' }} + name: ${{ github.ref == 'refs/heads/main' && 'production' || 'preview' }} url: ${{ steps.deploy.outputs.url }} env: VERCEL_ORG_ID: ${{ vars.VERCEL_ORG_ID }} VERCEL_PROJECT_ID: ${{ vars.VERCEL_PROJECT_ID }} VERCEL_TOKEN: ${{ secrets.VERCEL_TOKEN }} - DEPLOY_ENVIRONMENT: ${{ (github.ref == 'refs/heads/main' || github.ref_type == 'tag') && 'production' || 'preview' }} + DEPLOY_ENVIRONMENT: ${{ github.ref == 'refs/heads/main' && 'production' || 'preview' }} steps: - - uses: actions/checkout@v4 - - uses: actions/setup-node@v4 + - name: Check out repository + uses: actions/checkout@v7 + - name: Set up Node.js + uses: actions/setup-node@v7 with: node-version: 24 cache: npm - - run: npm ci - - run: npm run build:website + cache-dependency-path: package-lock.json + - name: Install dependencies + run: npm ci + - name: Build verified website + run: npm run build:website env: REQUIRE_NPM_PUBLICATION: 'true' - name: Deploy verified website diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..ca180a7 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,186 @@ +name: Release + +on: + release: + types: + - published + +permissions: + contents: read + +concurrency: + group: npm-nipe-solutions-react-data-inspector-stable + cancel-in-progress: false + +jobs: + verify: + if: github.event.release.prerelease == false + runs-on: ubuntu-24.04 + timeout-minutes: 60 + outputs: + tarball: ${{ steps.release.outputs.tarball }} + steps: + - name: Check out repository + uses: actions/checkout@v7 + with: + fetch-depth: 0 + - name: Require a release from main + run: git merge-base --is-ancestor HEAD origin/main + - name: Set up Node.js + uses: actions/setup-node@v7 + with: + node-version: 24 + cache: npm + cache-dependency-path: package-lock.json + - name: Install dependencies + run: npm ci + - name: Audit dependencies + run: npm audit --audit-level=moderate + - name: Check formatting + run: npm run format:check + - name: Check types + run: npm run typecheck + - name: Run unit and component tests + run: npm test + - name: Check release policy + run: npm run test:release-policy + - name: Build package + run: npm run build + - name: Build website + run: npm run build:website + - name: Verify website + run: npm run test:website + - name: Install browser engines + run: npx playwright install --with-deps chromium firefox webkit + - name: Run browser suite + run: npm run test:e2e + - name: Verify and preserve release artifact + id: release + run: npm run release:check -- --dry-run --output release-artifact + - name: Run model benchmarks + run: npm run benchmark + - name: Upload verified release artifact + uses: actions/upload-artifact@v7 + with: + name: npm-package-stable + path: release-artifact + if-no-files-found: error + retention-days: 7 + + publish: + needs: verify + environment: npm + runs-on: ubuntu-24.04 + timeout-minutes: 10 + permissions: + id-token: write + steps: + - name: Set up Node.js for trusted publishing + uses: actions/setup-node@v7 + with: + node-version: 24 + registry-url: https://registry.npmjs.org + - name: Download verified release artifact + uses: actions/download-artifact@v8 + with: + name: npm-package-stable + path: release-artifact + - name: Validate and publish verified artifact + env: + RELEASE_TARBALL: ${{ needs.verify.outputs.tarball }} + working-directory: release-artifact + run: | + set -euo pipefail + + expected_tarball="nipe-solutions-react-data-inspector-1.0.0.tgz" + manifest_file="release-manifest.json" + if [[ ! "$RELEASE_TARBALL" =~ ^[A-Za-z0-9._-]+$ ]] || [[ "$RELEASE_TARBALL" != "$expected_tarball" ]]; then + echo "Unsafe release tarball: expected $expected_tarball" >&2 + exit 1 + fi + + shopt -s nullglob dotglob + entries=(*) + if (( ${#entries[@]} != 2 )); then + echo "Artifact must contain only the tarball and release manifest" >&2 + exit 1 + fi + if [[ ! -f "$RELEASE_TARBALL" ]] || [[ ! -f "$manifest_file" ]]; then + echo "Verified tarball or release manifest is missing" >&2 + exit 1 + fi + + node --input-type=module <<'NODE' + import assert from 'node:assert/strict' + import { createHash } from 'node:crypto' + import { readFile } from 'node:fs/promises' + + const tarball = process.env.RELEASE_TARBALL + const manifest = JSON.parse(await readFile('release-manifest.json', 'utf8')) + const expectedFiles = [ + 'LICENSE', + 'README.md', + 'dist/index.cjs', + 'dist/index.d.cts', + 'dist/index.d.ts', + 'dist/index.js', + 'dist/styles.css', + 'package.json', + ] + const version = process.env.GITHUB_REF_NAME.replace(/^v/, '') + assert.equal(process.env.GITHUB_REF_NAME, `v${version}`) + assert.deepEqual(Object.keys(manifest).sort(), [ + 'channel', + 'filename', + 'files', + 'integrity', + 'name', + 'sha512', + 'version', + ]) + assert.equal(manifest.name, '@nipe-solutions/react-data-inspector') + assert.equal(manifest.version, version) + assert.equal(manifest.version, '1.0.0') + assert.equal(manifest.channel, 'latest') + assert.equal(manifest.filename, tarball) + assert.deepEqual(manifest.files, expectedFiles) + assert.match(manifest.sha512, /^[0-9a-f]{128}$/) + assert.match(manifest.integrity, /^sha512-[A-Za-z0-9+/]+={0,2}$/) + + const digest = createHash('sha512').update(await readFile(tarball)).digest() + assert.equal(manifest.sha512, digest.toString('hex')) + assert.equal(manifest.integrity, `sha512-${digest.toString('base64')}`) + NODE + + npm publish --ignore-scripts --provenance --access public --tag latest "$RELEASE_TARBALL" + + deploy: + name: Deploy released website + needs: publish + runs-on: ubuntu-24.04 + environment: + name: production + url: ${{ steps.deploy.outputs.url }} + env: + VERCEL_ORG_ID: ${{ vars.VERCEL_ORG_ID }} + VERCEL_PROJECT_ID: ${{ vars.VERCEL_PROJECT_ID }} + VERCEL_TOKEN: ${{ secrets.VERCEL_TOKEN }} + DEPLOY_ENVIRONMENT: production + steps: + - name: Check out repository + uses: actions/checkout@v7 + - name: Set up Node.js + uses: actions/setup-node@v7 + with: + node-version: 24 + cache: npm + cache-dependency-path: package-lock.json + - name: Install dependencies + run: npm ci + - name: Build published website + run: npm run build:website + env: + REQUIRE_NPM_PUBLICATION: 'true' + - name: Deploy released website + id: deploy + run: node scripts/deploy-website.mjs diff --git a/package-lock.json b/package-lock.json index 086a6fb..4c74dcf 100644 --- a/package-lock.json +++ b/package-lock.json @@ -25,7 +25,8 @@ "tsx": "^4.20.0", "typescript": "~6.0.3", "vite": "^8.2.2", - "vitest": "^5.0.0" + "vitest": "^5.0.0", + "yaml": "2.9.1" }, "peerDependencies": { "react": "^18.3.0 || ^19.0.0", @@ -2911,6 +2912,22 @@ "node": ">=10" } }, + "node_modules/yaml": { + "version": "2.9.1", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.9.1.tgz", + "integrity": "sha512-3NxN8+78OdzbT7C/WjGsyfPAtJaN3FNDsWxv7Y7mcDsT/oOmgW8BpyQQFFBnvZE3j9Y2Sdz1ULFLezL7Eb2yFw==", + "dev": true, + "license": "ISC", + "bin": { + "yaml": "bin.mjs" + }, + "engines": { + "node": ">= 14.6" + }, + "funding": { + "url": "https://github.com/sponsors/eemeli" + } + }, "node_modules/yargs": { "version": "17.7.3", "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.3.tgz", diff --git a/package.json b/package.json index 2399149..5ccb430 100644 --- a/package.json +++ b/package.json @@ -76,7 +76,8 @@ "tsx": "^4.20.0", "typescript": "~6.0.3", "vite": "^8.2.2", - "vitest": "^5.0.0" + "vitest": "^5.0.0", + "yaml": "2.9.1" }, "prettier": { "semi": false, diff --git a/scripts/verify-workflows.test.mjs b/scripts/verify-workflows.test.mjs new file mode 100644 index 0000000..54273ef --- /dev/null +++ b/scripts/verify-workflows.test.mjs @@ -0,0 +1,433 @@ +import assert from 'node:assert/strict' +import { execFile } from 'node:child_process' +import { createHash } from 'node:crypto' +import { + chmod, + mkdir, + mkdtemp, + readFile, + rename, + rm, + writeFile, +} from 'node:fs/promises' +import { tmpdir } from 'node:os' +import path from 'node:path' +import process from 'node:process' +import test from 'node:test' +import { promisify } from 'node:util' +import { parse } from 'yaml' + +const execFileAsync = promisify(execFile) +const repositoryRoot = path.resolve(import.meta.dirname, '..') +const releaseTarball = 'nipe-solutions-react-data-inspector-1.0.0.tgz' +const expectedFiles = JSON.parse( + await readFile( + path.join(repositoryRoot, 'scripts/package-files.json'), + 'utf8', + ), +) + +test('CI retains the React matrix without publication privileges', async () => { + validateCiWorkflow(await readWorkflow('.github/workflows/ci.yml')) +}) + +test('release separates verification, publication, and deployment', async () => { + validateReleaseWorkflow(await readWorkflow('.github/workflows/release.yml')) +}) + +test('publish shell accepts only the verified artifact pair', async () => { + const workflow = await readWorkflow('.github/workflows/release.yml') + const script = workflow.jobs.publish.steps.at(-1).run + + await withArtifact(async ({ artifactDirectory, binaryDirectory, marker }) => { + await runPublishScript(script, artifactDirectory, binaryDirectory, marker) + assert.equal( + await readFile(marker, 'utf8'), + `publish --ignore-scripts --provenance --access public --tag latest ${releaseTarball}\n`, + ) + }) + + const mutations = [ + async ({ artifactDirectory }) => + writeFile(path.join(artifactDirectory, 'extra.txt'), 'unexpected\n'), + async ({ artifactDirectory }) => + rename( + path.join(artifactDirectory, releaseTarball), + path.join(artifactDirectory, 'substituted.tgz'), + ), + async ({ manifestPath, manifest }) => + writeManifest(manifestPath, { ...manifest, filename: 'substituted.tgz' }), + async ({ manifestPath, manifest }) => + writeManifest(manifestPath, { ...manifest, version: '1.0.1' }), + async ({ manifestPath, manifest }) => + writeManifest(manifestPath, { ...manifest, channel: 'beta' }), + async ({ artifactDirectory }) => + writeFile( + path.join(artifactDirectory, releaseTarball), + 'changed tarball\n', + ), + async ({ manifestPath, manifest }) => + writeManifest(manifestPath, { + ...manifest, + files: manifest.files.slice(1), + }), + ] + + for (const mutate of mutations) { + await withArtifact(async (fixture) => { + await mutate(fixture) + await assert.rejects(() => + runPublishScript( + script, + fixture.artifactDirectory, + fixture.binaryDirectory, + fixture.marker, + ), + ) + await assert.rejects(() => readFile(fixture.marker, 'utf8'), /ENOENT/) + }) + } +}) + +test('workflow validators reject weakened trust boundaries', async () => { + const ci = await readWorkflow('.github/workflows/ci.yml') + const ciMutations = [ + (copy) => (copy.permissions = { contents: 'write' }), + (copy) => (copy.jobs.test.strategy.matrix.react = ['19.3.0']), + (copy) => (copy.jobs.publish = { 'runs-on': 'ubuntu-24.04' }), + (copy) => (copy.jobs.test.permissions = { 'id-token': 'write' }), + (copy) => delete findAction(copy.jobs.test, 'actions/upload-artifact').if, + (copy) => + (findAction(copy.jobs.test, 'actions/upload-artifact').with[ + 'retention-days' + ] = 30), + (copy) => + (findRun(copy.jobs.test, 'npm run check')['continue-on-error'] = true), + (copy) => (copy.jobs.deploy.if = 'always()'), + (copy) => delete findRun(copy.jobs.deploy, 'npm run build:website').env, + ] + for (const mutate of ciMutations) { + const copy = structuredClone(ci) + mutate(copy) + assert.throws(() => validateCiWorkflow(copy)) + } + + const release = await readWorkflow('.github/workflows/release.yml') + const releaseMutations = [ + (copy) => (copy.on.release.types = ['created']), + (copy) => delete copy.jobs.verify.if, + (copy) => (copy.permissions = { 'id-token': 'write' }), + (copy) => (copy.jobs.verify.permissions = { 'id-token': 'write' }), + (copy) => (copy.concurrency.group = 'release-${{ github.ref }}'), + (copy) => delete findAction(copy.jobs.verify, 'actions/checkout').with, + (copy) => + (findRun(copy.jobs.verify, 'git merge-base').run = 'git status --short'), + (copy) => + (findRun(copy.jobs.verify, 'npm run release:check').run = + 'npm pack --dry-run'), + (copy) => + (findRun(copy.jobs.verify, 'npm run test:e2e')['continue-on-error'] = + true), + (copy) => + (findAction(copy.jobs.verify, 'actions/upload-artifact').with.name = + 'different-artifact'), + (copy) => (copy.jobs.publish.environment = 'unprotected'), + (copy) => (copy.jobs.publish.permissions = { contents: 'write' }), + (copy) => copy.jobs.publish.steps.unshift({ uses: 'actions/checkout@v7' }), + (copy) => + (findAction(copy.jobs.publish, 'actions/download-artifact').with.name = + 'different-artifact'), + (copy) => (copy.jobs.deploy.permissions = { 'id-token': 'write' }), + (copy) => delete findRun(copy.jobs.deploy, 'npm run build:website').env, + ] + for (const mutate of releaseMutations) { + const copy = structuredClone(release) + mutate(copy) + assert.throws(() => validateReleaseWorkflow(copy)) + } +}) + +async function readWorkflow(relativePath) { + return parse(await readFile(path.join(repositoryRoot, relativePath), 'utf8')) +} + +async function withArtifact(assertion) { + const temporaryRoot = await mkdtemp( + path.join(tmpdir(), 'react-data-inspector-publish-'), + ) + const artifactDirectory = path.join(temporaryRoot, 'artifact') + const binaryDirectory = path.join(temporaryRoot, 'bin') + const marker = path.join(temporaryRoot, 'published') + const manifestPath = path.join(artifactDirectory, 'release-manifest.json') + try { + await Promise.all([mkdir(artifactDirectory), mkdir(binaryDirectory)]) + const npmShim = path.join(binaryDirectory, 'npm') + await writeFile( + npmShim, + '#!/bin/sh\nprintf "%s\\n" "$*" > "$PUBLISH_MARKER"\n', + ) + await chmod(npmShim, 0o755) + const tarballPath = path.join(artifactDirectory, releaseTarball) + const tarball = Buffer.from('verified tarball\n') + await writeFile(tarballPath, tarball) + const digest = createHash('sha512').update(tarball).digest() + const manifest = { + name: '@nipe-solutions/react-data-inspector', + version: '1.0.0', + channel: 'latest', + filename: releaseTarball, + integrity: `sha512-${digest.toString('base64')}`, + sha512: digest.toString('hex'), + files: expectedFiles, + } + await writeManifest(manifestPath, manifest) + await assertion({ + artifactDirectory, + binaryDirectory, + manifest, + manifestPath, + marker, + }) + } finally { + await rm(temporaryRoot, { recursive: true, force: true }) + } +} + +function writeManifest(manifestPath, manifest) { + return writeFile(manifestPath, `${JSON.stringify(manifest, null, 2)}\n`) +} + +function runPublishScript(script, artifactDirectory, binaryDirectory, marker) { + return execFileAsync('bash', ['-c', script], { + cwd: artifactDirectory, + env: { + ...process.env, + GITHUB_REF_NAME: 'v1.0.0', + PATH: `${binaryDirectory}:${process.env.PATH}`, + PUBLISH_MARKER: marker, + RELEASE_TARBALL: releaseTarball, + }, + }) +} + +function stepsFor(job) { + assert.ok(Array.isArray(job?.steps), 'job must define executable steps') + return job.steps +} + +function runCommands(job) { + return stepsFor(job) + .map((step) => step.run) + .filter((command) => typeof command === 'string') +} + +function assertRequiredCommands(job, expected) { + assert.deepEqual(runCommands(job), expected) + for (const command of expected) { + const step = stepsFor(job).find((candidate) => candidate.run === command) + assert.ok(step, `${command} must be an exact required step`) + assert.equal(step.if, undefined) + assert.equal(step['continue-on-error'], undefined) + } +} + +function findAction(job, action) { + const step = stepsFor(job).find((candidate) => + candidate.uses?.startsWith(`${action}@`), + ) + assert.ok(step, `${action} step is required`) + return step +} + +function findRun(job, fragment) { + const step = stepsFor(job).find((candidate) => + candidate.run?.includes(fragment), + ) + assert.ok(step, `${fragment} command is required`) + return step +} + +function assertActionMajor(job, action, major = 7) { + assert.equal(findAction(job, action).uses, `${action}@v${major}`) +} + +function assertNodeSetup(job, { registry = false } = {}) { + assertActionMajor(job, 'actions/setup-node') + const setup = findAction(job, 'actions/setup-node') + assert.equal(String(setup.with?.['node-version']), '24') + if (registry) { + assert.equal(setup.with?.['registry-url'], 'https://registry.npmjs.org') + assert.equal(setup.with?.cache, undefined) + } else { + assert.equal(setup.with?.cache, 'npm') + assert.equal(setup.with?.['cache-dependency-path'], 'package-lock.json') + } +} + +function validateCiWorkflow(workflow) { + assert.deepEqual(workflow.permissions, { contents: 'read' }) + assert.equal( + workflow.concurrency.group, + 'react-data-inspector-ci-${{ github.ref }}', + ) + assert.equal(workflow.concurrency['cancel-in-progress'], false) + assert.deepEqual(Object.keys(workflow.jobs).sort(), [ + 'deploy', + 'test', + 'verify', + ]) + assert.doesNotMatch(JSON.stringify(workflow), /npm publish/) + assert.doesNotMatch(JSON.stringify(workflow), /"id-token":"write"/) + + const matrix = workflow.jobs.test + assert.equal(matrix['runs-on'], 'ubuntu-24.04') + assert.ok(matrix['timeout-minutes'] > 0) + assert.ok(matrix['timeout-minutes'] <= 60) + assert.equal(matrix.strategy?.['fail-fast'], false) + assert.deepEqual(matrix.strategy?.matrix?.react, ['18.3.1', '19.3.0']) + assertActionMajor(matrix, 'actions/checkout') + assertNodeSetup(matrix) + assertRequiredCommands(matrix, [ + 'npm ci', + 'npm install --no-save --ignore-scripts react@${{ matrix.react }} react-dom@${{ matrix.react }}', + 'npm run check', + 'npx playwright install --with-deps chromium firefox webkit', + 'npm run test:e2e', + 'npm run benchmark', + ]) + const artifact = findAction(matrix, 'actions/upload-artifact') + assertActionMajor(matrix, 'actions/upload-artifact') + assert.equal(artifact.if, 'failure()') + assert.equal(artifact['continue-on-error'], true) + assert.equal(artifact.with?.name, 'evidence-react-${{ matrix.react }}') + assert.equal(artifact.with?.['if-no-files-found'], 'ignore') + assert.ok(artifact.with?.['retention-days'] <= 7) + assert.match(artifact.with?.path ?? '', /test-results/) + assert.match(artifact.with?.path ?? '', /benchmarks\/results\.json/) + + const verify = workflow.jobs.verify + assert.equal(verify.if, 'always()') + assert.equal(verify.needs, 'test') + assert.deepEqual(runCommands(verify), ['test "$RESULT" = success']) + + const deploy = workflow.jobs.deploy + assert.equal(deploy.needs, 'verify') + assert.equal( + deploy.if, + "needs.verify.result == 'success' && github.event_name != 'pull_request' && github.ref_type != 'tag'", + ) + assert.equal(deploy.permissions, undefined) + assertActionMajor(deploy, 'actions/checkout') + assertNodeSetup(deploy) + assertRequiredCommands(deploy, [ + 'npm ci', + 'npm run build:website', + 'node scripts/deploy-website.mjs', + ]) + assert.deepEqual(findRun(deploy, 'npm run build:website').env, { + REQUIRE_NPM_PUBLICATION: 'true', + }) +} + +function validateReleaseWorkflow(workflow) { + assert.deepEqual(workflow.on, { release: { types: ['published'] } }) + assert.deepEqual(workflow.permissions, { contents: 'read' }) + assert.equal( + workflow.concurrency.group, + 'npm-nipe-solutions-react-data-inspector-stable', + ) + assert.equal(workflow.concurrency['cancel-in-progress'], false) + assert.deepEqual(Object.keys(workflow.jobs).sort(), [ + 'deploy', + 'publish', + 'verify', + ]) + + const verify = workflow.jobs.verify + assert.equal(verify.if, 'github.event.release.prerelease == false') + assert.equal(verify['runs-on'], 'ubuntu-24.04') + assert.ok(verify['timeout-minutes'] > 0) + assert.ok(verify['timeout-minutes'] <= 60) + assert.deepEqual(verify.outputs, { + tarball: '${{ steps.release.outputs.tarball }}', + }) + assert.equal(verify.permissions, undefined) + assertActionMajor(verify, 'actions/checkout') + assert.equal(findAction(verify, 'actions/checkout').with?.['fetch-depth'], 0) + assertNodeSetup(verify) + assertRequiredCommands(verify, [ + 'git merge-base --is-ancestor HEAD origin/main', + 'npm ci', + 'npm audit --audit-level=moderate', + 'npm run format:check', + 'npm run typecheck', + 'npm test', + 'npm run test:release-policy', + 'npm run build', + 'npm run build:website', + 'npm run test:website', + 'npx playwright install --with-deps chromium firefox webkit', + 'npm run test:e2e', + 'npm run release:check -- --dry-run --output release-artifact', + 'npm run benchmark', + ]) + const releaseStep = findRun(verify, 'npm run release:check') + assert.equal(releaseStep.id, 'release') + const upload = findAction(verify, 'actions/upload-artifact') + assertActionMajor(verify, 'actions/upload-artifact') + assert.equal(upload.with?.name, 'npm-package-stable') + assert.equal(upload.with?.path, 'release-artifact') + assert.equal(upload.with?.['if-no-files-found'], 'error') + assert.ok(upload.with?.['retention-days'] <= 7) + assert.ok( + stepsFor(verify).indexOf(upload) > stepsFor(verify).indexOf(releaseStep), + ) + + const publish = workflow.jobs.publish + assert.equal(publish.needs, 'verify') + assert.equal(publish.environment, 'npm') + assert.equal(publish['runs-on'], 'ubuntu-24.04') + assert.ok(publish['timeout-minutes'] > 0) + assert.ok(publish['timeout-minutes'] <= 10) + assert.deepEqual(publish.permissions, { 'id-token': 'write' }) + assert.equal( + stepsFor(publish).some((step) => + step.uses?.startsWith('actions/checkout@'), + ), + false, + ) + assertNodeSetup(publish, { registry: true }) + assertActionMajor(publish, 'actions/download-artifact', 8) + const download = findAction(publish, 'actions/download-artifact') + assert.equal(download.with?.name, 'npm-package-stable') + assert.equal(download.with?.path, 'release-artifact') + assert.equal(runCommands(publish).length, 1) + + const finalStep = publish.steps.at(-1) + assert.equal(finalStep.name, 'Validate and publish verified artifact') + assert.equal(finalStep['working-directory'], 'release-artifact') + assert.equal( + finalStep.env?.RELEASE_TARBALL, + '${{ needs.verify.outputs.tarball }}', + ) + assert.doesNotMatch(finalStep.run, /npm (ci|install|run)|vite|tsc/) + assert.equal((finalStep.run.match(/npm publish/g) ?? []).length, 1) + assert.match( + finalStep.run, + /npm publish --ignore-scripts --provenance --access public --tag latest "\$RELEASE_TARBALL"/, + ) + + const deploy = workflow.jobs.deploy + assert.equal(deploy.needs, 'publish') + assert.equal(deploy.permissions, undefined) + assertActionMajor(deploy, 'actions/checkout') + assertNodeSetup(deploy) + assertRequiredCommands(deploy, [ + 'npm ci', + 'npm run build:website', + 'node scripts/deploy-website.mjs', + ]) + assert.deepEqual(findRun(deploy, 'npm run build:website').env, { + REQUIRE_NPM_PUBLICATION: 'true', + }) +} From 4eeecfdbbc32c2731825314be1194e7362d8055a Mon Sep 17 00:00:00 2001 From: Nicholas Petrasek Date: Wed, 30 Sep 2026 11:39:29 +0200 Subject: [PATCH 4/6] release: prepare react-data-inspector 1.0.0 --- CHANGELOG.md | 10 +++++- README.md | 8 ++--- docs/architecture.md | 4 +-- docs/delivery-audit.md | 6 ++-- docs/deployment.md | 2 +- docs/release-readiness.md | 51 ++++++++++++++++--------------- docs/releasing.md | 20 ++++++------ package-lock.json | 48 ++++++++++++++--------------- package.json | 12 ++++---- scripts/build-website.mjs | 10 +++--- scripts/validate-release.test.mjs | 21 +++++++++++++ scripts/verify-website.mjs | 19 ++++++++++++ website/App.tsx | 4 +-- website/docs/accessibility.md | 2 +- website/docs/getting-started.md | 2 +- website/docs/installation.md | 2 +- website/docs/introduction.md | 2 +- website/docs/limitations.md | 2 +- website/playground/Playground.tsx | 2 +- website/playground/Validation.tsx | 5 ++- website/playground/scenarios.tsx | 2 +- website/site/Chrome.tsx | 5 ++- 22 files changed, 146 insertions(+), 93 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ab6418c..ba500e9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,14 @@ # Changelog -## 0.1.0-alpha.0 — unreleased +## 1.0.0 — 2026-09-30 + +Stable read-only inspector for JavaScript object graphs, including real JavaScript types, circular and shared references, descriptor-safe discovery, controlled expansion and selection, bounded search, grouped large values, keyboard navigation, scoped styling, targeted slots, custom types and actions, classic presentation, and unstyled mode. + +The stable contract supports React 18.3 and React 19, TypeScript, ESM and CommonJS, server rendering and hydration. Package verification now installs the exact tarball into isolated React 18 and 19 consumers, enforces the public archive inventory and size budgets, and prevents machine-level npm configuration from affecting release evidence. + +Publication uses a separately verified artifact, npm trusted publishing, provenance, and a protected release workflow. Automated unit, browser, accessibility, website, package, and benchmark coverage accompanies the release; the remaining human assistive-technology and physical-device qualification work is documented in [release readiness](docs/release-readiness.md). + +## 0.1.0-beta.0 — 2026-09-07 Initial read-only inspector with real JavaScript types, circular/shared reference tracking, descriptor-safe child discovery, grouped large values, bounded search, controlled expansion and selection, copying, keyboard navigation, CSS variables, slots, custom types/actions, and unstyled mode. diff --git a/README.md b/README.md index f081634..7641ae0 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ A structured JavaScript data inspector for React: inspect the value you actually have. -**Beta release channel.** [Website](https://react-data-inspector.nipesolutions.com) · [Playground](https://react-data-inspector.nipesolutions.com/playground) · [Documentation](https://react-data-inspector.nipesolutions.com/docs). Run `npm install && npm run dev` locally. See [release readiness](docs/release-readiness.md) before production adoption. +**Stable 1.0 release.** [Website](https://react-data-inspector.nipesolutions.com) · [Playground](https://react-data-inspector.nipesolutions.com/playground) · [Documentation](https://react-data-inspector.nipesolutions.com/docs). Run `npm install && npm run dev` locally. Review the [release evidence and remaining manual audits](docs/release-readiness.md) for your adoption requirements. ```tsx import { DataInspector } from '@nipe-solutions/react-data-inspector' @@ -11,10 +11,10 @@ import '@nipe-solutions/react-data-inspector/styles.css' ; ``` -Install the beta release: +Install the package: ```sh -npm install @nipe-solutions/react-data-inspector@beta +npm install @nipe-solutions/react-data-inspector ``` ## Why this exists @@ -53,7 +53,7 @@ Prefer quoted keys and object-viewer syntax? Set `presentation="classic"`; see [ See the [compiled customization examples](examples/customization.tsx), [API reference](docs/api.md), [architecture and type matrix](docs/architecture.md), and [performance methodology](benchmarks/README.md). -The beta is read-only; editing is not part of the public API. Getters are not evaluated, promises are not awaited, functions are not executed, and WeakMap/WeakSet contents remain opaque. Collection paths are not presented as JSON Pointers. +The inspector is read-only; editing is not part of the public API. Getters are not evaluated, promises are not awaited, functions are not executed, and WeakMap/WeakSet contents remain opaque. Collection paths are not presented as JSON Pointers. ## Development diff --git a/docs/architecture.md b/docs/architecture.md index f1ff15d..327f98a 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -45,7 +45,7 @@ Built-in subclasses are identified through bounded prototype descriptor inspecti The labelled tree owns nested treeitems and groups. Items have explicit level, position and sibling count. The tree itself is the navigation tab stop and uses aria-activedescendant pointing to a mounted item. Focus is independent of selection. Collapsing or removing a focused descendant falls back to a surviving ancestor. Keyboard controls include arrows, Home/End, type-ahead, Enter activation (toggle a branch, follow a reference, or select a leaf), Space selection, and F2 actions. Clicking reference text reveals and selects its original; the action menu provides the same jump. Search Enter advances and Shift+Enter moves backwards. -Search controls and the current-node action panel are outside the tree. They use normal buttons, status regions and form controls. Custom content slots retain library-owned tree and toggle semantics. Uniform measured rows support bounded windowing; custom variable-height rows require disabling virtualization. The public-beta gate includes actual screen-reader testing; axe alone cannot establish this. +Search controls and the current-node action panel are outside the tree. They use normal buttons, status regions and form controls. Custom content slots retain library-owned tree and toggle semantics. Uniform measured rows support bounded windowing; custom variable-height rows require disabling virtualization. Human screen-reader testing remains adopter qualification; axe alone cannot establish compatibility. ## Security and SSR @@ -57,7 +57,7 @@ SSR performs no browser-global access. React useId prevents cross-instance ID co ## Roadmap -Before public beta: manual assistive technology audit, real application integration, stronger memory/performance evidence, uniform-row windowing audit and resolution of any remaining correctness defects. Then primitive immutable editing proposals, collection continuation beyond the current cap, and explicit extra-array-property discovery. JSON Patch helpers, worker search, prototype views, hex mode and diffing require separate justification. No AI features or JavaScript evaluator. +Future work may include primitive immutable editing proposals, collection continuation beyond the current cap, and explicit extra-array-property discovery. JSON Patch helpers, worker search, prototype views, hex mode, and diffing require separate design and justification. Human assistive-technology audits, real application integration, and physical-device measurements remain adopter qualification rather than feature claims. ## Search during updates diff --git a/docs/delivery-audit.md b/docs/delivery-audit.md index fb6caa8..87ace02 100644 --- a/docs/delivery-audit.md +++ b/docs/delivery-audit.md @@ -34,8 +34,8 @@ This audit maps the product deliverables to the implementation and honest releas | 28. README | Concise usage, differentiation, customization, docs and MIT | | 29. Limitations | [Architecture](architecture.md) and [readiness](release-readiness.md) | | 30. Roadmap | Inspection-first; editing/continuation/integration after audited core | -| 31. Naming | Verified NIPE conventions, public GitHub repository and unpublished npm candidate | -| 32. Readiness | **Beta distribution**; outstanding manual production-qualification audits | +| 31. Naming | Verified NIPE conventions, public GitHub repository, and scoped npm package | +| 32. Readiness | **Stable 1.0 distribution**; adopter-specific manual qualification remains documented | ## Mandatory customization scenarios @@ -55,6 +55,6 @@ Cycle and shared-reference tests distinguish semantics without recursion; getter ## Accessibility audit -Keyboard-only behavior, collapse/focus restoration, selection, search, actions, RTL/mobile and windowed active descendants have automated interaction coverage and live browser inspection. Axe passes the default and dark examples. No human screen-reader audit was performed. Editing accessibility is not applicable to this read-only beta. See the remaining production-qualification work before describing the project as production-ready. +Keyboard-only behavior, collapse/focus restoration, selection, search, actions, RTL/mobile, and windowed active descendants have automated interaction coverage and live browser inspection. Axe passes the default and dark examples. No human screen-reader audit was performed. Editing accessibility is not applicable to this read-only component. See the remaining adopter qualification before relying on it in a particular production environment. The dedicated [playground](playground.md) now includes externally updated data, typed-value scenarios, styling controls, lazy children and opt-in performance measurements. Standalone live examples are verified against the packed package. diff --git a/docs/deployment.md b/docs/deployment.md index 92dd830..26ae42b 100644 --- a/docs/deployment.md +++ b/docs/deployment.md @@ -13,7 +13,7 @@ The website uses the `react-data-inspector` project in the NIPE Solutions Vercel The GitHub `VERCEL_TOKEN` secret is scoped to this project and expires on 2027-09-07. Rotate it before expiry through Vercel and update the GitHub secret. `VERCEL_ORG_ID` and `VERCEL_PROJECT_ID` are repository variables. Optional diagnostic artifact uploads are non-blocking when GitHub storage is unavailable; tests, builds and packaging remain required. -Version-tag builds publish the beta npm package after both React matrix jobs pass, then deploy the website. Ordinary branch pushes deploy the site without publishing packages. Application secrets are unnecessary for this static site. Keep `.vercel/` local and ignored. +Publishing a non-prerelease GitHub Release runs the separate Release workflow, which verifies and publishes the stable npm artifact before deploying registry-backed website copy. Ordinary branch pushes deploy the site without publishing packages, and tag pushes do not deploy through CI. Application secrets are unnecessary for this static site. Keep `.vercel/` local and ignored. ## Domain diff --git a/docs/release-readiness.md b/docs/release-readiness.md index 227237a..24ce1b9 100644 --- a/docs/release-readiness.md +++ b/docs/release-readiness.md @@ -1,48 +1,51 @@ # Release readiness -Distribution: **beta**. Manual production-qualification audits remain open. +Distribution: **stable 1.0**. Manual integration and device qualification remain open. -Assessed 2026-09-07. Version: 0.1.0-beta.0. Beta publication is performed by the verified version-tag workflow; check the [npm beta channel](https://www.npmjs.com/package/@nipe-solutions/react-data-inspector?activeTab=versions) and [release workflow](https://github.com/NIPE-Solutions/react-data-inspector/actions/workflows/ci.yml) for registry status. See [deployment](deployment.md). +Assessed 2026-09-30. Version: 1.0.0. Stable publication is performed by the protected [Release workflow](https://github.com/NIPE-Solutions/react-data-inspector/actions/workflows/release.yml); check the [npm versions](https://www.npmjs.com/package/@nipe-solutions/react-data-inspector?activeTab=versions) and workflow result for registry status. See [deployment](deployment.md). ## Verified -- Descriptor-safe object graphs: cycles, shared references, sparse arrays, symbols, throwing/revoked proxies, subclasses, collection limits, custom source laziness. -- Controlled expansion, independent selection, reference jumps, search reveal across canonical ownership changes, retained collapse state, cancellation and bounded search. -- Primitive and JSON-compatible copying without getter/toJSON execution; lossy subtrees are refused. Clipboard fallback and failed actions are localized. -- React 18.3.1 and 19.2.8 unit/component, SSR and hydration checks. Strict Mode and same-reference parent updates are covered. -- Chromium, Firefox and WebKit interaction/axe tests, including 390px mobile layout, RTL, grouped large arrays, virtualized focus and CSS row-height updates. -- ESM and CJS tarball imports, declarations, CSS export and SSR in a clean consumer fixture. No runtime dependencies beyond React peers. -- Runnable customization cases for CSS-only brand colors, toggle-only replacement, added application actions, Money, unstyled mode, controlled expansion and adjacent selection details. -- Repeatable model, SSR, search, browser interaction and model-lifetime benchmarks with raw output and methodology. +- Descriptor-safe object graphs: cycles, shared references, sparse arrays, symbols, throwing/revoked proxies, subclasses, collection limits, and lazy custom sources. +- Controlled expansion, independent selection, reference jumps, search reveal across canonical ownership changes, retained collapse state, cancellation, and bounded search. +- Primitive and JSON-compatible copying without getter or `toJSON` execution; lossy subtrees are refused. Clipboard fallback and failed actions are localized. +- React 18.3.1 and 19.3.0 unit/component, SSR, and hydration checks. Strict Mode and same-reference parent updates are covered. +- Chromium, Firefox, and WebKit interaction/axe tests, including 390px mobile layout, RTL, grouped large arrays, virtualized focus, and CSS row-height updates. +- ESM and CommonJS tarball imports, declarations, CSS export, and SSR in clean consumer fixtures. React and React DOM are the only runtime peers. +- Runnable customization cases for CSS-only brand colors, toggle-only replacement, added application actions, Money, unstyled mode, controlled expansion, and adjacent selection details. +- Repeatable model, SSR, search, browser interaction, and model-lifetime benchmarks with raw output and methodology. -The harness covers unit/component behavior and browser interactions, including search during random updates every 50 ms, documentation without JavaScript, website hydration, classic presentation, legal routes, documentation content search, and mobile layouts. CI runs the full checks for React 18.3.1 and 19; use the commit-specific workflow result and test summaries as current verification evidence. The website build verifies every indexed route and compiles the public documentation examples. Counts are reported by the tools rather than fixed here as the suite grows. +The harness covers unit/component behavior and browser interactions, including search during random updates every 50 ms, documentation without JavaScript, website hydration, classic presentation, legal routes, documentation content search, and mobile layouts. CI runs the full checks for React 18.3.1 and 19.3.0; use the commit-specific workflow result and test summaries as current verification evidence. The website build verifies every indexed route and compiles the public documentation examples. Counts are reported by the tools rather than fixed here as the suite grows. -The browser runner is pinned to Playwright 1.58.2 to support the WebKit build available on this macOS host. A newer runner failed before opening a page with an unsupported WebKit protocol setting. This is not evidence of current Safari/iOS support; current-browser CI and actual mobile Safari checks remain necessary. +The browser runner is pinned to Playwright 1.58.2 to support the WebKit build available on this macOS host. This is not evidence of current Safari or iOS support; current-browser CI and physical-device checks remain necessary. -The [playground](playground.md) adds repeatable live application-state simulations and opt-in measurement tools. It is not a completed production integration or manual audit. +The [playground](playground.md) provides repeatable live application-state simulations and opt-in measurement tools. It is not a completed production integration or human accessibility audit. -## Measurements and limits +## Measurements and stable boundaries See [model results](../benchmarks/results.json), [browser results](../benchmarks/browser-results.json), [memory experiment](../benchmarks/memory-results.json), and [methodology](../benchmarks/README.md). Opening the 500,000-item array produces 51 first-level model rows with defaults. The 100,000-property object must synchronously enumerate its own keys. Search yields between batches and can return partial results under its limits. Custom sources load at most 100 entries per call: the 1,000-child regression fixture needs 10 calls instead of 1,000. Search stops child discovery at its node/result budget. Visible row and ownership indexes are reused across internal focus and scroll updates, while parent renders still inspect a fresh generation. Browser wall-clock action timings include automation overhead and search debounce; they are not pure React render timings. -The forced-GC experiment observed all 50 discarded model roots collected. It does not establish absence of leaks in mounted React components, browser heaps or application-held callback contexts. No frame-loss or retained-browser-heap claim is made. +The forced-GC experiment observed all 50 discarded model roots collected. It does not establish absence of leaks in mounted React components, browser heaps, or application-held callback contexts. No frame-loss or retained-browser-heap claim is made. -## Remaining production-qualification work +The stable 1.0 contract deliberately includes uniform measured rows for virtualization, indexed array inspection, the first 10,000 Map/Set entries, bounded search and reveal, and reference targets scoped to the current discovery generation. These are supported limits rather than promises of unbounded inspection. -1. Perform and record actual VoiceOver/Safari and NVDA/Firefox or Chrome navigation, selection, search, actions and virtualization audits. Keyboard automation and axe are insufficient. -2. Dogfood through the public API in an application such as source metadata/job payload inspection. No application integration has been performed. -3. Audit mounted-browser retained heaps across repeated input replacement/unmount, and scrolling frame timing on representative hardware. -4. Validate current desktop/mobile Safari and current evergreen browser versions beyond the locally available pinned WebKit. -5. Review the deliberately limited beta contracts: uniform-height virtualization, first 10,000 Map/Set entries, indexed-only arrays, bounded search/reveal and discovery-scoped reference targets. +## Remaining adopter qualification + +1. Perform and record VoiceOver/Safari and NVDA/Firefox or Chrome navigation, selection, search, actions, and virtualization audits. Keyboard automation and axe are insufficient. +2. Dogfood the public API in a representative application such as source metadata or job-payload inspection. +3. Audit mounted-browser retained heaps across repeated input replacement/unmount and scrolling frame timing on representative hardware. +4. Validate current desktop and mobile Safari plus the evergreen browser versions required by the adopting application. + +These checks depend on a real application, human assistive-technology use, or physical devices and therefore are not claimed by this release. ## Deferred features -Editing, add/remove proposals, JSON Patch conversion, collection continuation, prototype inspection and specialized binary/DOM views are not implemented. There is no editing prop that implies otherwise. See the current [public API](api.md) and [limitations](https://react-data-inspector.nipesolutions.com/limitations). +Editing, add/remove proposals, JSON Patch conversion, collection continuation, prototype inspection, and specialized binary/DOM views are not implemented. There is no editing prop that implies otherwise. See the current [public API](api.md) and [limitations](https://react-data-inspector.nipesolutions.com/limitations). ## Naming and publication -The public GitHub repository and npm package are `NIPE-Solutions/react-data-inspector` and `@nipe-solutions/react-data-inspector`. The first beta is `0.1.0-beta.0`, published with dist-tag `beta`; a beta version does not claim stable API or completion of the manual audits above. +The public GitHub repository and npm package are `NIPE-Solutions/react-data-inspector` and `@nipe-solutions/react-data-inspector`. Version 1.0.0 is published with the `latest` dist-tag through trusted publishing. Stable API status does not imply completion of the adopter-specific audits above. -GitHub Actions checks React 18/19, builds the package, publishes a matching version tag, and deploys the website after publication so the real npm installation command is visible. Future publication uses package-scoped trusted publishing. See [release process](releasing.md). +GitHub Actions verifies React 18.3.1 and 19.3.0, builds and tests the package, publishes the artifact associated with a matching GitHub Release, and deploys the website only after npm publication succeeds. See the [release process](releasing.md). diff --git a/docs/releasing.md b/docs/releasing.md index 6078b05..8b60ab3 100644 --- a/docs/releasing.md +++ b/docs/releasing.md @@ -1,15 +1,17 @@ -# Beta releases +# Stable releases -The package uses versions such as `0.1.0-beta.0` and the npm `beta` dist-tag. It does not promote prereleases to `latest`. +Stable versions use the npm `latest` dist-tag. Publication starts from a non-prerelease GitHub Release and uses the reviewed artifact produced by the release workflow; local machines do not publish the package. -1. Update `package.json` and the lockfile with `npm version 0.1.0-beta.N --no-git-tag-version`. -2. Commit the reviewed change to `main`. -3. Push a matching tag: `git tag v0.1.0-beta.N` then `git push origin v0.1.0-beta.N`. +1. Update `package.json`, `package-lock.json`, and `CHANGELOG.md` to the same stable version. +2. Run `npm run release:check -- --dry-run` and the full verification suite. +3. Merge the reviewed release commit to `main`. +4. Publish a GitHub Release whose tag is exactly `v`, such as `v1.0.0`. +5. Confirm the Release workflow publishes the package and then deploys the website. -The Verify workflow runs the complete React 18.3.1/19 matrix, including unit, browser, SSR, package and website checks. Its publication job requires the tag to equal the package version, the lockfile to match, the commit to belong to main, and the version to be absent from npm. Publication uses public access, `beta`, and provenance. Existing versions are never overwritten; bump the version for another release. +The Release workflow requires the release commit to belong to `main`, the GitHub tag to match the package and lockfile versions, the release to be non-prerelease, and the version to be absent from npm. It tests React 18.3.1 and 19.3.0 through CI, reruns the complete release gate, and preserves an exact tarball plus its signed-off manifest. The publishing job downloads only that artifact, validates its name, inventory, version, channel, and SHA-512 digest, then publishes it with public access, `latest`, and provenance. Existing versions are immutable; prepare a new version for every subsequent release. -npm trusted publishing is configured for organization `NIPE-Solutions`, repository `react-data-inspector`, workflow `ci.yml`, environment `npm`, with direct publish allowed. GitHub-hosted runners supply OIDC credentials. The bootstrap credential used for first publication has been retired. Future releases use OIDC without a stored npm token. Never commit credentials. +npm trusted publishing is configured for organization `NIPE-Solutions`, repository `react-data-inspector`, workflow `release.yml`, and environment `npm`. GitHub-hosted runners obtain short-lived credentials through OIDC. Do not add an npm token or publish a locally built archive. -A successful tag publication is followed by a production website build, which checks npm dist-tag endpoints and displays the real installation command. Deployment retries briefly while the registry propagates a new package, then fails rather than deploying unpublished-package copy. Failed publication blocks that release deployment. Ordinary main pushes continue to deploy verified website updates without publishing a package. +After npm accepts the artifact, the workflow rebuilds the production website against registry metadata and deploys it. Registry propagation is retried for a bounded period; publication or metadata verification failures prevent deployment. Ordinary verified branch pushes continue to deploy website updates without publishing a package. -The beta channel is a release designation. It does not imply that the manual screen-reader, current mobile Safari, dogfooding and retained-browser-heap audits in [release readiness](release-readiness.md) are complete. +Stable describes the documented API and compatibility contract. Human screen-reader, current mobile Safari, application dogfooding, and retained-browser-heap audits remain explicit adopter qualification work in [release readiness](release-readiness.md). diff --git a/package-lock.json b/package-lock.json index 4c74dcf..1578dea 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@nipe-solutions/react-data-inspector", - "version": "0.1.0-beta.0", + "version": "1.0.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@nipe-solutions/react-data-inspector", - "version": "0.1.0-beta.0", + "version": "1.0.0", "license": "MIT", "devDependencies": { "@axe-core/playwright": "^4.13.0", @@ -14,14 +14,14 @@ "@testing-library/jest-dom": "^6.9.1", "@testing-library/react": "^16.3.3", "@types/node": "^24.0.0", - "@types/react": "^19.2.0", - "@types/react-dom": "^19.2.0", + "@types/react": "19.3.0", + "@types/react-dom": "19.3.0", "dts-bundle-generator": "9.5.1", "jsdom": "^27.0.0", "marked": "^18.0.11", "prettier": "3.9.6", - "react": "^19.2.8", - "react-dom": "^19.2.8", + "react": "19.3.0", + "react-dom": "19.3.0", "tsx": "^4.20.0", "typescript": "~6.0.3", "vite": "^8.2.2", @@ -1186,9 +1186,9 @@ } }, "node_modules/@types/react": { - "version": "19.2.18", - "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.18.tgz", - "integrity": "sha512-AnzbBERsrLKtk2XSfTbYRLjQPdy116Sty4q+T+Bp3IC4l6jNBvreVPAHmpq9qhXQM7CXZPjLVmGMw9sy+hxQ3w==", + "version": "19.3.0", + "resolved": "https://registry.npmjs.org/@types/react/-/react-19.3.0.tgz", + "integrity": "sha512-N0rFCuH9YoxG9/m61l9MfpJKfmLOVU0em7ipIz6TRgSSkvReLB9vL85GB+yr8Bs5leqpvg96JSwF4ZS1s4viQg==", "dev": true, "license": "MIT", "dependencies": { @@ -1196,13 +1196,13 @@ } }, "node_modules/@types/react-dom": { - "version": "19.2.7", - "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.7.tgz", - "integrity": "sha512-I8bPpDLcHBv1qiIiXDCy71Rt8eQDKJP0sMSWJphDdAcdqiJ1sGpZamavoEIRZmYzjia9LuEb2HlYdDpmoENpvQ==", + "version": "19.3.0", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.3.0.tgz", + "integrity": "sha512-ZI7bU42mZXXKHn/qNLEw2IrbiINU7X5+vfgdixBHkCNpYWXjKgfQ/P+uyGb5CjOLB9UcnTeg3rylQtV2hym44Q==", "dev": true, "license": "MIT", "peerDependencies": { - "@types/react": "^19.2.0" + "@types/react": "^19.3.0" } }, "node_modules/@vitest/mocker": { @@ -2284,9 +2284,9 @@ } }, "node_modules/react": { - "version": "19.2.8", - "resolved": "https://registry.npmjs.org/react/-/react-19.2.8.tgz", - "integrity": "sha512-PWaYA1L/q9u2u7xYQi+Y3L3Yfnie7XyLeaJICV1MGD6LprsBxcAqGjYyr0eY3p+QdsA+x/Irkt4Qif8D63+Sbw==", + "version": "19.3.0", + "resolved": "https://registry.npmjs.org/react/-/react-19.3.0.tgz", + "integrity": "sha512-E8LUcbtBWt20bbl2YoHfx4ZDBdxVTfOKtCZn9cDSJ4l6/nuoApcpIBcj47t2wZoVX8g2ZHuMHbiShgCR1T5Sog==", "dev": true, "license": "MIT", "engines": { @@ -2294,16 +2294,16 @@ } }, "node_modules/react-dom": { - "version": "19.2.8", - "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.8.tgz", - "integrity": "sha512-rVprimfGBG3DR+Tq0IQG2DT5PxKth1WIGDmj5yPmlzr4YBe7uyE+Du4oVqTDXZSHGGGXRtTJEGSSePyQCMBglQ==", + "version": "19.3.0", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.3.0.tgz", + "integrity": "sha512-JDk8dgif51OjFoDE70+OT9ICyYr+69HlmihNwp1+Nsfbna3t5sIiCa9ZJktDmQ4/1b/rn26hIAR2uYXDMr5r0Q==", "dev": true, "license": "MIT", "dependencies": { - "scheduler": "^0.27.0" + "scheduler": "^0.28.0" }, "peerDependencies": { - "react": "^19.2.8" + "react": "^19.3.0" } }, "node_modules/react-is": { @@ -2396,9 +2396,9 @@ } }, "node_modules/scheduler": { - "version": "0.27.0", - "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", - "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==", + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.28.0.tgz", + "integrity": "sha512-juorfCmIkIw8tT+p5BXSm6PJjQF/ycEYmKyzURCIt/RaZIhL+PulbQ9Yu2z1HdOJDdqDTlxA1+xKBmHXJsczAw==", "dev": true, "license": "MIT" }, diff --git a/package.json b/package.json index 5ccb430..9775384 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@nipe-solutions/react-data-inspector", - "version": "0.1.0-beta.0", + "version": "1.0.0", "description": "A structured JavaScript data inspector for React.", "type": "module", "license": "MIT", @@ -36,7 +36,7 @@ }, "publishConfig": { "access": "public", - "tag": "beta", + "tag": "latest", "provenance": true }, "scripts": { @@ -65,14 +65,14 @@ "@testing-library/jest-dom": "^6.9.1", "@testing-library/react": "^16.3.3", "@types/node": "^24.0.0", - "@types/react": "^19.2.0", - "@types/react-dom": "^19.2.0", + "@types/react": "19.3.0", + "@types/react-dom": "19.3.0", "dts-bundle-generator": "9.5.1", "jsdom": "^27.0.0", "marked": "^18.0.11", "prettier": "3.9.6", - "react": "^19.2.8", - "react-dom": "^19.2.8", + "react": "19.3.0", + "react-dom": "19.3.0", "tsx": "^4.20.0", "typescript": "~6.0.3", "vite": "^8.2.2", diff --git a/scripts/build-website.mjs b/scripts/build-website.mjs index d990f39..f15275b 100644 --- a/scripts/build-website.mjs +++ b/scripts/build-website.mjs @@ -2,11 +2,13 @@ import { spawnSync } from 'node:child_process' import { mkdir, readFile, writeFile, copyFile, rm } from 'node:fs/promises' import { dirname, resolve } from 'node:path' import { pathToFileURL } from 'node:url' -// Resolve dist-tags directly: the package document can retain a cached 404 -// briefly after first publication even when version endpoints are available. const requiredPublication = process.env.REQUIRE_NPM_PUBLICATION === 'true' -let version = '' -for (let attempt = 0; attempt < (requiredPublication ? 6 : 1); attempt++) { +const packageJson = JSON.parse(await readFile('package.json', 'utf8')) +// Local and verification builds describe their source candidate. Deployment +// builds resolve npm directly and refuse installation copy for an unpublished +// version. The existing beta is a bridge only until the first stable publish. +let version = requiredPublication ? '' : packageJson.version +for (let attempt = 0; attempt < (requiredPublication ? 6 : 0); attempt++) { for (const tag of ['latest', 'beta']) { const response = await fetch( `https://registry.npmjs.org/@nipe-solutions%2freact-data-inspector/${tag}`, diff --git a/scripts/validate-release.test.mjs b/scripts/validate-release.test.mjs index 70094fc..8d44fba 100644 --- a/scripts/validate-release.test.mjs +++ b/scripts/validate-release.test.mjs @@ -45,6 +45,27 @@ test('stable metadata selects the latest channel', () => { ) }) +test('repository metadata satisfies stable release policy', async () => { + const [packageJson, packageLock, changelog] = await Promise.all([ + readFile(path.join(repositoryRoot, 'package.json'), 'utf8').then( + JSON.parse, + ), + readFile(path.join(repositoryRoot, 'package-lock.json'), 'utf8').then( + JSON.parse, + ), + readFile(path.join(repositoryRoot, 'CHANGELOG.md'), 'utf8'), + ]) + + assert.deepEqual( + validateReleaseMetadata(packageJson, packageLock, changelog), + { + name: stablePackage.name, + version: '1.0.0', + channel: 'latest', + }, + ) +}) + test('stable metadata rejects invalid versions and release policy', () => { for (const version of ['1.0.0-beta.1', 'v1.0.0', '01.0.0', '1.0']) { assert.throws( diff --git a/scripts/verify-website.mjs b/scripts/verify-website.mjs index 77af9e5..75eea16 100644 --- a/scripts/verify-website.mjs +++ b/scripts/verify-website.mjs @@ -37,6 +37,25 @@ for (const url of urls) { } if (new Set([...pages.values()].map((doc) => doc.title)).size !== pages.size) throw Error('Duplicate page titles') +const renderedText = [...pages.values()] + .map((doc) => doc.body.textContent ?? '') + .join('\n') +for (const phrase of [ + 'Beta preview', + 'Beta. Inspection core', + 'This is an beta release', + 'current beta', + 'distributed on the beta channel', +]) { + if (renderedText.includes(phrase)) + throw Error(`Current-beta copy remains in the stable website: ${phrase}`) +} +if ( + !renderedText.includes( + 'npm install @nipe-solutions/react-data-inspector@1.0.0', + ) +) + throw Error('Stable installation command is missing its concrete version') for (const [path, doc] of pages) { for (const anchor of doc.querySelectorAll('a[href]')) { const href = anchor.getAttribute('href') diff --git a/website/App.tsx b/website/App.tsx index 07d6031..402d38b 100644 --- a/website/App.tsx +++ b/website/App.tsx @@ -133,8 +133,8 @@ export function App() {

- Beta. Inspection core available; editing and manual accessibility - audits remain ahead.{' '} + Stable inspection API; editing remains out of scope and manual + accessibility qualification is documented.{' '} Read the limitations.

diff --git a/website/docs/accessibility.md b/website/docs/accessibility.md index 994f5c5..7e26e27 100644 --- a/website/docs/accessibility.md +++ b/website/docs/accessibility.md @@ -6,6 +6,6 @@ Selection, keyboard focus, and search matches have distinct states. Search contr ## Current verification status -Automated keyboard interaction and axe checks have run in Chromium, Firefox, and WebKit, including a 390px layout, RTL, grouping, and virtualized focus. SSR and hydration have been exercised with React 18.3.1 and 19.2.8. +Automated keyboard interaction and axe checks have run in Chromium, Firefox, and WebKit, including a 390px layout, RTL, grouping, and virtualized focus. SSR and hydration have been exercised with React 18.3.1 and 19.3.0. These checks do not establish screen-reader compatibility. Manual VoiceOver with Safari and NVDA with Firefox or Chrome have not been completed and remain release-readiness work. Test the exact data shapes, slots, messages, browser versions, and assistive technologies required by your application. diff --git a/website/docs/getting-started.md b/website/docs/getting-started.md index dc9a6b8..3665df4 100644 --- a/website/docs/getting-started.md +++ b/website/docs/getting-started.md @@ -63,4 +63,4 @@ Choose [classic presentation](/guides/presentation) for a more traditional objec ## Check the boundary before adoption -Read the [limitations](/limitations) and [accessibility audit status](/accessibility) for the current beta. A successful local example does not replace testing with your application's data, customizations, and assistive technologies. +Read the [limitations](/limitations) and [accessibility audit status](/accessibility) before adoption. A successful local example does not replace testing with your application's data, customizations, browsers, and assistive technologies. diff --git a/website/docs/installation.md b/website/docs/installation.md index c228922..7f7cef3 100644 --- a/website/docs/installation.md +++ b/website/docs/installation.md @@ -26,4 +26,4 @@ import { DataInspector } from '@nipe-solutions/react-data-inspector' const html = renderToString() ``` -Hydration still follows normal React rules: provide equivalent input, controlled paths, messages, and custom type definitions on server and client. Browser-only slot code or callbacks can make your integration unsafe for SSR even though the built-in component is safe. Automated SSR and hydration checks cover React 18.3.1 and 19.2.8; this is not a claim about every framework's streaming or server-component boundary. +Hydration still follows normal React rules: provide equivalent input, controlled paths, messages, and custom type definitions on server and client. Browser-only slot code or callbacks can make your integration unsafe for SSR even though the built-in component is safe. Automated SSR and hydration checks cover React 18.3.1 and 19.3.0; this is not a claim about every framework's streaming or server-component boundary. diff --git a/website/docs/introduction.md b/website/docs/introduction.md index 0cbda75..bd9cad0 100644 --- a/website/docs/introduction.md +++ b/website/docs/introduction.md @@ -17,4 +17,4 @@ export function Example() { The component provides path-based expansion and selection, bounded search, conservative copying, reference navigation, custom domain types, and targeted render slots. It is read-only: editing, patch generation, schema forms, and diffing are outside the current API. -This is an beta release. React 18.3.1 and 19.2.8 have automated unit, component, SSR, hydration, keyboard, and axe coverage. Manual VoiceOver and NVDA audits remain incomplete; evaluate the component against your own browser and assistive-technology requirements. +Version 1.0 provides a stable read-only inspection API. React 18.3.1 and 19.3.0 have automated unit, component, SSR, hydration, keyboard, and axe coverage. Human VoiceOver and NVDA audits remain adopter qualification; evaluate the component against your own browser and assistive-technology requirements. diff --git a/website/docs/limitations.md b/website/docs/limitations.md index a97876d..75eb1d6 100644 --- a/website/docs/limitations.md +++ b/website/docs/limitations.md @@ -11,4 +11,4 @@ The component does not edit values, add or remove properties, emit JSON Patch, inspect prototypes, evaluate JavaScript, render schema forms, or diff snapshots. Default copying rejects graph semantics or values that cannot be represented without loss. Row virtualization assumes a uniform measured height. -Automated tests cover keyboard behavior, axe, SSR, hydration, and React 18.3.1 and 19.2.8. Manual screen-reader audits, current mobile Safari validation, mounted-browser heap analysis, and production application dogfooding are still outstanding. +Automated tests cover keyboard behavior, axe, SSR, hydration, and React 18.3.1 and 19.3.0. Manual screen-reader audits, current mobile Safari validation, mounted-browser heap analysis, and production application dogfooding are still outstanding. diff --git a/website/playground/Playground.tsx b/website/playground/Playground.tsx index e3417f0..aad29d9 100644 --- a/website/playground/Playground.tsx +++ b/website/playground/Playground.tsx @@ -74,7 +74,7 @@ export function Playground() { then take the code.

- Beta preview + Stable 1.0