Skip to content

Commit f00758b

Browse files
committed
fix: close stable release verification gaps
1 parent 8aef1fb commit f00758b

12 files changed

Lines changed: 366 additions & 81 deletions

‎.github/workflows/release.yml‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,10 @@ jobs:
5959
run: npm run release:check -- --dry-run --output release-artifact
6060
- name: Run model benchmarks
6161
run: npm run benchmark
62+
- name: Run model lifetime benchmark
63+
run: npm run benchmark:memory
64+
- name: Run browser benchmark
65+
run: npm run benchmark:browser
6266
- name: Upload verified release artifact
6367
uses: actions/upload-artifact@v7
6468
with:
@@ -181,6 +185,7 @@ jobs:
181185
run: npm run build:website
182186
env:
183187
REQUIRE_NPM_PUBLICATION: 'true'
188+
EXPECTED_NPM_VERSION: '1.0.0'
184189
- name: Deploy released website
185190
id: deploy
186191
run: node scripts/deploy-website.mjs

‎scripts/build-website.mjs‎

Lines changed: 9 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -2,41 +2,18 @@ import { spawnSync } from 'node:child_process'
22
import { mkdir, readFile, writeFile, copyFile, rm } from 'node:fs/promises'
33
import { dirname, resolve } from 'node:path'
44
import { pathToFileURL } from 'node:url'
5+
import { resolvePublishedVersion } from './website-version.mjs'
56
const requiredPublication = process.env.REQUIRE_NPM_PUBLICATION === 'true'
67
const packageJson = JSON.parse(await readFile('package.json', 'utf8'))
78
// Local and verification builds describe their source candidate. Deployment
8-
// builds resolve npm directly and refuse installation copy for an unpublished
9-
// version. The existing beta is a bridge only until the first stable publish.
10-
let version = requiredPublication ? '' : packageJson.version
11-
for (let attempt = 0; attempt < (requiredPublication ? 6 : 0); attempt++) {
12-
for (const tag of ['latest', 'beta']) {
13-
const response = await fetch(
14-
`https://registry.npmjs.org/@nipe-solutions%2freact-data-inspector/${tag}`,
15-
{ signal: AbortSignal.timeout(20000) },
16-
)
17-
if (response.ok) {
18-
const data = await response.json()
19-
version = data.version
20-
if (
21-
typeof version !== 'string' ||
22-
!/^\d+\.\d+\.\d+(?:-[\w.-]+)?$/.test(version)
23-
)
24-
throw Error('Registry returned no valid release version')
25-
break
26-
}
27-
if (response.status !== 404)
28-
throw Error(`Cannot verify npm publication: ${response.status}`)
29-
}
30-
if (version) break
31-
if (requiredPublication && attempt < 5) {
32-
console.log('Waiting for npm publication to become readable…')
33-
await new Promise((resolve) => setTimeout(resolve, 10000))
34-
}
35-
}
36-
if (requiredPublication && !version)
37-
throw Error(
38-
'Published package is not readable; refusing stale installation copy',
39-
)
9+
// builds resolve npm directly. Release deployment additionally requires the
10+
// exact reviewed version on latest; the prerelease bridge is available only to
11+
// ordinary non-release deployments during the transition.
12+
const version = await resolvePublishedVersion({
13+
sourceVersion: packageJson.version,
14+
requirePublication: requiredPublication,
15+
expectedVersion: process.env.EXPECTED_NPM_VERSION,
16+
})
4017
process.env.VITE_NPM_VERSION = version
4118
const run = (args) => {
4219
const result = spawnSync('npx', args, { stdio: 'inherit', env: process.env })

‎scripts/documentation-snippets.mjs‎

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
import { readFile, readdir } from 'node:fs/promises'
2+
import path from 'node:path'
3+
4+
const competitorImport =
5+
/from ['"](?:react18-json-view|react-json-view-lite|@uiw\/react-json-view)['"]/
6+
7+
export async function readDocumentationSnippets(repositoryRoot) {
8+
const documentationDirectory = path.join(repositoryRoot, 'website/docs')
9+
const snippets = []
10+
11+
for (const file of (await readdir(documentationDirectory)).sort()) {
12+
const source = await readFile(
13+
path.join(documentationDirectory, file),
14+
'utf8',
15+
)
16+
let index = 0
17+
for (const match of source.matchAll(/```tsx?\n([\s\S]*?)```/g)) {
18+
if (competitorImport.test(match[1])) continue
19+
snippets.push({
20+
filename: `${file}-${index++}.tsx`,
21+
code: match[1],
22+
})
23+
}
24+
}
25+
26+
return snippets
27+
}
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
import assert from 'node:assert/strict'
2+
import path from 'node:path'
3+
import test from 'node:test'
4+
import { readDocumentationSnippets } from './documentation-snippets.mjs'
5+
6+
const repositoryRoot = path.resolve(import.meta.dirname, '..')
7+
8+
test('documentation snippets select every current package example', async () => {
9+
const snippets = await readDocumentationSnippets(repositoryRoot)
10+
11+
assert.equal(snippets.length, 24)
12+
assert.equal(new Set(snippets.map(({ filename }) => filename)).size, 24)
13+
assert.ok(
14+
snippets.every(
15+
({ code }) =>
16+
!/from ['"](?:react18-json-view|react-json-view-lite|@uiw\/react-json-view)['"]/.test(
17+
code,
18+
),
19+
),
20+
)
21+
assert.ok(
22+
snippets.some(({ code }) =>
23+
code.includes("from '@nipe-solutions/react-data-inspector'"),
24+
),
25+
)
26+
})

‎scripts/validate-release.mjs‎

Lines changed: 18 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,10 @@
11
import assert from 'node:assert/strict'
22
import { execFile } from 'node:child_process'
33
import console from 'node:console'
4-
import { createHash } from 'node:crypto'
4+
import { createHash, randomUUID } from 'node:crypto'
55
import {
66
appendFile,
7+
link,
78
mkdir,
89
mkdtemp,
910
readFile,
@@ -16,8 +17,9 @@ import process from 'node:process'
1617
import { pathToFileURL } from 'node:url'
1718
import { promisify } from 'node:util'
1819
import {
20+
createNpmPaths,
1921
formatCommandError,
20-
sanitizeNpmEnvironment,
22+
runNpm,
2123
validatePackedFiles,
2224
verifyTarballConsumers,
2325
} from './verify-package.mjs'
@@ -225,8 +227,8 @@ export async function verifyRelease({ dryRun = false, outputDirectory } = {}) {
225227
const npmPaths = await createNpmPaths(workingRoot)
226228
await mkdir(packDirectory)
227229
const { stdout } = await runNpm(
228-
['pack', '--json', '--pack-destination', packDirectory],
229-
repositoryRoot,
230+
['pack', repositoryRoot, '--json', '--pack-destination', packDirectory],
231+
npmPaths.workspace,
230232
npmPaths,
231233
)
232234
const [pack] = JSON.parse(stdout)
@@ -270,15 +272,13 @@ export async function verifyRelease({ dryRun = false, outputDirectory } = {}) {
270272
release.channel,
271273
tarballPath,
272274
],
273-
repositoryRoot,
275+
npmPaths.workspace,
274276
npmPaths,
275277
)
276278

277279
const manifest = await createArtifactManifest(pack, tarballPath, release)
278280
const manifestPath = path.join(packDirectory, 'release-manifest.json')
279-
await writeFile(manifestPath, `${JSON.stringify(manifest, null, 2)}\n`, {
280-
flag: 'wx',
281-
})
281+
await writeArtifactManifest(manifestPath, manifest)
282282
if (outputDirectory) {
283283
await writeGitHubOutputs({ tarball: pack.filename })
284284
console.log(`Verified artifact preserved at ${tarballPath}`)
@@ -292,25 +292,23 @@ export async function verifyRelease({ dryRun = false, outputDirectory } = {}) {
292292
}
293293
}
294294

295-
async function createNpmPaths(temporaryRoot) {
296-
const paths = {
297-
userConfig: path.join(temporaryRoot, 'user.npmrc'),
298-
globalConfig: path.join(temporaryRoot, 'global.npmrc'),
299-
cache: path.join(temporaryRoot, 'npm-cache'),
295+
export async function writeArtifactManifest(manifestPath, manifest) {
296+
const temporaryPath = `${manifestPath}.${randomUUID()}.tmp`
297+
try {
298+
await writeFile(temporaryPath, `${JSON.stringify(manifest, null, 2)}\n`, {
299+
flag: 'wx',
300+
})
301+
await link(temporaryPath, manifestPath)
302+
} finally {
303+
await rm(temporaryPath, { force: true })
300304
}
301-
await Promise.all([
302-
writeFile(paths.userConfig, ''),
303-
writeFile(paths.globalConfig, ''),
304-
mkdir(paths.cache),
305-
])
306-
return paths
307305
}
308306

309307
async function readRegistryVersion(name, version, npmPaths) {
310308
try {
311309
const { stdout } = await runNpm(
312310
['view', `${name}@${version}`, 'version', '--json'],
313-
repositoryRoot,
311+
npmPaths.workspace,
314312
npmPaths,
315313
)
316314
const parsed = JSON.parse(stdout)
@@ -336,14 +334,6 @@ async function runVisibleNpm(args, cwd, npmPaths) {
336334
if (stderr) process.stderr.write(stderr)
337335
}
338336

339-
function runNpm(args, cwd, npmPaths) {
340-
return execFileAsync('npm', args, {
341-
cwd,
342-
env: sanitizeNpmEnvironment(process.env, npmPaths),
343-
maxBuffer: 10 * 1024 * 1024,
344-
})
345-
}
346-
347337
function run(command, args) {
348338
return execFileAsync(command, args, {
349339
cwd: repositoryRoot,

‎scripts/validate-release.test.mjs‎

Lines changed: 25 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import assert from 'node:assert/strict'
2-
import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'
2+
import { mkdtemp, readFile, readdir, rm, writeFile } from 'node:fs/promises'
33
import { tmpdir } from 'node:os'
44
import path from 'node:path'
55
import test from 'node:test'
@@ -9,6 +9,7 @@ import {
99
parseArguments,
1010
validateReleaseMetadata,
1111
validateRepositoryContext,
12+
writeArtifactManifest,
1213
} from './validate-release.mjs'
1314

1415
const repositoryRoot = path.resolve(import.meta.dirname, '..')
@@ -295,3 +296,26 @@ test('artifact manifest binds release metadata and inventory to the tarball', as
295296
await rm(directory, { recursive: true, force: true })
296297
}
297298
})
299+
300+
test('artifact manifest installation is atomic and exclusive', async () => {
301+
const temporaryRoot = await mkdtemp(
302+
path.join(tmpdir(), 'react-data-inspector-manifest-write-'),
303+
)
304+
const manifestPath = path.join(temporaryRoot, 'release-manifest.json')
305+
try {
306+
await writeArtifactManifest(manifestPath, { version: '1.0.0' })
307+
assert.deepEqual(JSON.parse(await readFile(manifestPath, 'utf8')), {
308+
version: '1.0.0',
309+
})
310+
await assert.rejects(
311+
() => writeArtifactManifest(manifestPath, { version: 'substituted' }),
312+
/EEXIST/,
313+
)
314+
assert.deepEqual(JSON.parse(await readFile(manifestPath, 'utf8')), {
315+
version: '1.0.0',
316+
})
317+
assert.deepEqual(await readdir(temporaryRoot), ['release-manifest.json'])
318+
} finally {
319+
await rm(temporaryRoot, { recursive: true, force: true })
320+
}
321+
})

‎scripts/verify-package.mjs‎

Lines changed: 62 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import process from 'node:process'
99
import { pathToFileURL } from 'node:url'
1010
import { promisify } from 'node:util'
1111
import { gzipSync } from 'node:zlib'
12+
import { readDocumentationSnippets } from './documentation-snippets.mjs'
1213

1314
const execFileAsync = promisify(execFile)
1415
const repositoryRoot = path.resolve(import.meta.dirname, '..')
@@ -54,7 +55,14 @@ const lifecycleScripts = [
5455
'postinstall',
5556
'prepublish',
5657
'prepublishOnly',
58+
'preprepare',
5759
'prepare',
60+
'postprepare',
61+
'dependencies',
62+
'prepack',
63+
'postpack',
64+
'publish',
65+
'postpublish',
5866
]
5967

6068
export function validatePackedFiles(actualFiles, expectedFiles) {
@@ -200,9 +208,10 @@ export async function verifyTarballConsumers(tarballPath) {
200208
await verifyModulesAndSsr(consumer)
201209
await verifyTypes(consumer)
202210
await verifyExamples(consumer)
211+
await verifyDocumentationExamples(consumer)
203212
await verifyStylesAndManifest(consumer, sourceManifest)
204213
console.log(
205-
`${lane.label}: ESM, CJS, types, CSS, examples, and SSR passed`,
214+
`${lane.label}: ESM, CJS, types, CSS, examples, docs, and SSR passed`,
206215
)
207216
}
208217
} finally {
@@ -229,8 +238,8 @@ export async function verifyPackage() {
229238
await readFile(path.join(repositoryRoot, 'package.json'), 'utf8'),
230239
)
231240
const { stdout } = await runNpm(
232-
['pack', '--json', '--pack-destination', packDirectory],
233-
repositoryRoot,
241+
['pack', repositoryRoot, '--json', '--pack-destination', packDirectory],
242+
npmPaths.workspace,
234243
npmPaths,
235244
)
236245
const [pack] = JSON.parse(stdout)
@@ -430,6 +439,52 @@ async function verifyExamples(consumer) {
430439
)
431440
}
432441

442+
async function verifyDocumentationExamples(consumer) {
443+
const directory = path.join(consumer, 'documentation')
444+
await mkdir(directory)
445+
for (const { filename, code } of await readDocumentationSnippets(
446+
repositoryRoot,
447+
))
448+
await writeFile(path.join(directory, filename), code)
449+
await writeFile(
450+
path.join(directory, 'environment.d.ts'),
451+
"declare module '*.css'\n",
452+
)
453+
454+
await writeFile(
455+
path.join(consumer, 'tsconfig.documentation.json'),
456+
`${JSON.stringify(
457+
{
458+
compilerOptions: {
459+
target: 'ES2022',
460+
lib: ['ES2022', 'DOM', 'DOM.Iterable'],
461+
module: 'ESNext',
462+
moduleResolution: 'Bundler',
463+
jsx: 'react-jsx',
464+
strict: true,
465+
exactOptionalPropertyTypes: true,
466+
noUncheckedIndexedAccess: true,
467+
noEmit: true,
468+
skipLibCheck: false,
469+
types: [],
470+
},
471+
include: ['documentation/**/*.tsx', 'documentation/**/*.d.ts'],
472+
},
473+
null,
474+
2,
475+
)}\n`,
476+
)
477+
await run(
478+
process.execPath,
479+
[
480+
'node_modules/typescript/bin/tsc',
481+
'--project',
482+
'tsconfig.documentation.json',
483+
],
484+
consumer,
485+
)
486+
}
487+
433488
async function verifyStylesAndManifest(consumer, sourceManifest) {
434489
const require = createRequire(path.join(consumer, 'package.json'))
435490
const stylesheetPath = require.resolve(`${packageName}/styles.css`)
@@ -446,21 +501,23 @@ async function verifyStylesAndManifest(consumer, sourceManifest) {
446501
})
447502
}
448503

449-
async function createNpmPaths(temporaryRoot) {
504+
export async function createNpmPaths(temporaryRoot) {
450505
const paths = {
451506
userConfig: path.join(temporaryRoot, 'user.npmrc'),
452507
globalConfig: path.join(temporaryRoot, 'global.npmrc'),
453508
cache: path.join(temporaryRoot, 'npm-cache'),
509+
workspace: path.join(temporaryRoot, 'npm-workspace'),
454510
}
455511
await Promise.all([
456512
writeFile(paths.userConfig, ''),
457513
writeFile(paths.globalConfig, ''),
458514
mkdir(paths.cache),
515+
mkdir(paths.workspace),
459516
])
460517
return paths
461518
}
462519

463-
function runNpm(args, cwd, npmPaths) {
520+
export function runNpm(args, cwd, npmPaths) {
464521
return execFileAsync('npm', args, {
465522
cwd,
466523
env: sanitizeNpmEnvironment(process.env, npmPaths),

0 commit comments

Comments
 (0)