ReviewOS

also looking at this

craft-native/craft

fix(release): build and verify all npm package artifacts

#289
Merged glennmichael123 wants to merge codex/npm-release-artifacts into main
11 files +290 -3
.github/workflows/ci.ymlmodified+6-0
Changes to .github/workflows/ci.yml
@@ -320,6 +320,12 @@ jobs:
320320 - name: Verify publish surfaces
321321 run: bun run verify:packages
322322
323 - name: Test package regressions
324 run: bun run test:packages
325
326 - name: Build and verify every npm artifact
327 run: bun run verify:npm
328
323329 - name: Run root verification
324330 run: bun run verify
325331
.github/workflows/release.ymlmodified+6-0
Changes to .github/workflows/release.yml
@@ -430,6 +430,12 @@ jobs:
430430 - name: Install Dependencies
431431 run: bun install
432432
433 - name: Test package regressions
434 run: bun run test:packages
435
436 - name: Build and verify every npm artifact
437 run: bun run verify:npm
438
433439 - name: Publish to npm
434440 run: pantry publish --npm --access public
435441 env:
package.jsonmodified+4-1
Changes to package.json
@@ -25,10 +25,13 @@
2525 "build:desktop": "bun run build:core",
2626 "build:mobile": "bun run build:ios && bun run build:android",
2727 "build:web": "bun run build:sdk && bun run build:react && bun run build:vue && bun run build:svelte",
28 "build:all": "bun run build:core && bun run build:sdk && bun run build:ios && bun run build:android && bun run build:react && bun run build:vue && bun run build:svelte",
28 "build:all": "bun run build:core && bun run build:npm",
29 "build:npm": "bun scripts/npm-packages.ts --build-only",
2930 "build:check": "bun scripts/verify-builds.ts",
3031 "verify": "bun scripts/verify.ts",
3132 "verify:packages": "bun scripts/verify-packages.ts",
33 "verify:npm": "bun scripts/npm-packages.ts",
34 "test:packages": "bun test scripts/npm-packages.test.ts scripts/mobile-package.test.ts scripts/svelte-package.test.ts",
3235 "verify:create": "bun scripts/verify-create-craft.ts",
3336 "verify:cimports": "bun scripts/audit-cimports.ts",
3437 "verify:docs": "bun scripts/verify-doc-links.ts",
packages/android/build.tsmodified+11-1
Changes to packages/android/build.ts
@@ -2,7 +2,11 @@
22 * Build script for @craft-native/android
33 */
44
5await Bun.build({
5import { rm } from 'node:fs/promises'
6
7await rm('./dist', { recursive: true, force: true })
8
9const result = await Bun.build({
610 entrypoints: ['./src/index.ts'],
711 outdir: './dist',
812 target: 'bun',
@@ -11,4 +15,10 @@ await Bun.build({
1115 sourcemap: 'external',
1216})
1317
18if (!result.success)
19 throw new AggregateError(result.logs, 'Android builder compilation failed')
20
21// Use the locked compiler, as the SDK build does, rather than Pantry's PATH tsc.
22await Bun.$`bun ../typescript/scripts/tsc.ts src/index.ts --declaration --emitDeclarationOnly --module esnext --target esnext --moduleResolution bundler --types bun --skipLibCheck --ignoreConfig --outDir dist`
23
1424console.log('✅ Build complete')
packages/ios/build.tsmodified+5-0
Changes to packages/ios/build.ts
@@ -1,8 +1,13 @@
1import { rm } from 'node:fs/promises'
12import { $ } from 'bun'
23
4await rm('./dist', { recursive: true, force: true })
5
36// Build TypeScript
47await $`bun build src/index.ts --outdir dist --target bun`
58await $`bun build src/cli.ts --outdir dist --target bun`
9// The standalone package advertises this declaration independently of the SDK.
10await $`bun ../typescript/scripts/tsc.ts src/index.ts --declaration --emitDeclarationOnly --module esnext --target esnext --moduleResolution bundler --types bun --skipLibCheck --ignoreConfig --outDir dist`
611
712// Make CLI executable
813await $`chmod +x dist/cli.js`
packages/svelte/README.mdmodified+4-0
Changes to packages/svelte/README.md
@@ -8,6 +8,10 @@ Svelte bindings for the Craft framework using stores and actions.
88npm install @craft-native/svelte
99```
1010
11The package includes its TypeScript source for bundlers that select the `svelte`
12export condition. Other consumers use the compiled ESM or CommonJS entry in
13`dist`. Both entries expose the same stores and actions.
14
1115## Usage
1216
1317### Stores
packages/svelte/package.jsonmodified+2-1
Changes to packages/svelte/package.json
@@ -11,7 +11,8 @@
1111 "url": "https://github.com/craft-native/craft/issues"
1212 },
1313 "files": [
14 "dist"
14 "dist",
15 "src"
1516 ],
1617 "description": "Svelte bindings for Craft framework",
1718 "svelte": "src/index.ts",
scripts/mobile-package.test.tsadded+42-0
Changes to scripts/mobile-package.test.ts
@@ -0,0 +1,42 @@
1import { expect, test } from 'bun:test'
2import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'
3import { tmpdir } from 'node:os'
4import { join, resolve } from 'node:path'
5
6const root = resolve(import.meta.dir, '..')
7
8for (const platform of ['android', 'ios']) {
9 test(`${platform} tarball exposes usable standalone declarations`, () => {
10 const temp = mkdtempSync(join(tmpdir(), `craft-${platform}-package-`))
11 const packageDir = join(root, 'packages', platform)
12 const run = (args: string[], cwd = temp): void => {
13 const result = Bun.spawnSync(args, { cwd, stdout: 'pipe', stderr: 'pipe' })
14 expect(`${result.stdout}\n${result.stderr}`).not.toContain('error TS')
15 expect(result.exitCode).toBe(0)
16 }
17
18 try {
19 run([process.execPath, 'run', 'build'], packageDir)
20 run([process.execPath, 'pm', 'pack', '--filename', join(temp, 'package.tgz')], packageDir)
21 const installed = join(temp, 'node_modules', '@craft-native', platform)
22 mkdirSync(installed, { recursive: true })
23 run(['tar', '-xzf', join(temp, 'package.tgz'), '--strip-components=1', '-C', installed])
24 writeFileSync(join(temp, 'consumer.mts'), `
25import { init, type InitOptions } from '@craft-native/${platform}'
26const options: InitOptions = { name: 'Consumer', output: '/tmp/consumer', runtimeDir: null }
27const result: Promise<void> = init(options)
28// @ts-expect-error name must be a string, even outside the workspace
29init({ name: 42, output: '/tmp/consumer' })
30void result
31`)
32 run([
33 process.execPath, join(root, 'packages/typescript/scripts/tsc.ts'),
34 'consumer.mts', '--noEmit', '--strict', '--module', 'nodenext',
35 '--moduleResolution', 'nodenext', '--target', 'esnext', '--ignoreConfig',
36 ])
37 }
38 finally {
39 rmSync(temp, { recursive: true, force: true })
40 }
41 }, 60_000)
42}
scripts/npm-packages.test.tsadded+33-0
Changes to scripts/npm-packages.test.ts
@@ -0,0 +1,33 @@
1import { expect, test } from 'bun:test'
2import { manifestTargets, verifyArchiveTargets } from './npm-packages'
3
4test('rejects absent built entries even when the manifest advertises them', () => {
5 const pkg = { name: '@craft-native/react', main: 'dist/index.js', exports: { '.': { import: './dist/index.mjs', types: './dist/index.d.ts' } } }
6 expect(() => verifyArchiveTargets(pkg, ['package/package.json', 'package/README.md']))
7 .toThrow('packed artifact is missing dist/index.js')
8})
9
10test('checks each export condition, legacy field and string or named bin', () => {
11 const pkg = {
12 name: 'fixture', main: 'dist/index.cjs', module: 'dist/index.js', types: 'dist/index.d.ts', svelte: 'src/index.ts',
13 bin: { fixture: './dist/cli.js' },
14 exports: { '.': { types: './dist/index.d.ts', import: './dist/index.js', require: './dist/index.cjs', svelte: './src/index.ts' }, './mobile': './dist/mobile.js' },
15 }
16 const targets = manifestTargets(pkg)
17 expect(targets).toHaveLength(6)
18 const entries = targets.map(target => `package/${target}`)
19 expect(() => verifyArchiveTargets(pkg, entries)).not.toThrow()
20 for (const missing of entries)
21 expect(() => verifyArchiveTargets(pkg, entries.filter(entry => entry !== missing))).toThrow('packed artifact is missing')
22 expect(manifestTargets({ name: 'cli', bin: './bin/cli.ts' })).toEqual(['bin/cli.ts'])
23})
24
25test('a directory with the target name does not count as a file', () => {
26 expect(() => verifyArchiveTargets({ name: 'fixture', types: './dist/index.d.ts' }, ['package/dist/index.d.ts/']))
27 .toThrow('packed artifact is missing')
28})
29
30test('fails closed for targets that escape the package or need wildcard handling', () => {
31 for (const target of ['../outside.js', '/outside.js', './dist/../outside.js', '.\\outside.js', './dist/*.js'])
32 expect(() => manifestTargets({ name: 'fixture', exports: target })).toThrow()
33})
scripts/npm-packages.tsadded+144-0
Changes to scripts/npm-packages.ts
@@ -0,0 +1,144 @@
1import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, symlinkSync, writeFileSync } from 'node:fs'
2import { tmpdir } from 'node:os'
3import { dirname, join, resolve } from 'node:path'
4
5type Manifest = {
6 name: string
7 private?: boolean
8 scripts?: Record<string, string>
9 peerDependencies?: Record<string, string>
10 main?: string
11 module?: string
12 types?: string
13 svelte?: string
14 bin?: string | Record<string, string>
15 exports?: unknown
16}
17
18const root = resolve(import.meta.dir, '..')
19
20function run(args: string[], cwd: string): string {
21 const result = Bun.spawnSync(args, { cwd, stdout: 'pipe', stderr: 'pipe' })
22 if (result.exitCode !== 0)
23 throw new Error(`${args.join(' ')} failed in ${cwd}\n${result.stdout}\n${result.stderr}`)
24 return result.stdout.toString()
25}
26
27export function manifestTargets(pkg: Manifest): string[] {
28 const targets = new Set<string>()
29 const add = (value: unknown): void => {
30 if (typeof value === 'string') {
31 if (value.includes('*'))
32 throw new Error(`${pkg.name}: wildcard targets require explicit archive validation: ${value}`)
33 const target = value.replace(/^\.\//, '')
34 if (target.startsWith('/') || target.split('/').includes('..') || target.includes('\\'))
35 throw new Error(`${pkg.name}: invalid package target ${value}`)
36 targets.add(target)
37 }
38 else if (value && typeof value === 'object') {
39 Object.values(value).forEach(add)
40 }
41 }
42 for (const value of [pkg.main, pkg.module, pkg.types, pkg.svelte, pkg.bin, pkg.exports])
43 add(value)
44 return [...targets]
45}
46
47export function verifyArchiveTargets(pkg: Manifest, entries: string[]): void {
48 const files = new Set(entries.filter(entry => !entry.endsWith('/')))
49 for (const target of manifestTargets(pkg)) {
50 if (!files.has(`package/${target}`))
51 throw new Error(`${pkg.name}: packed artifact is missing ${target}`)
52 }
53}
54
55function publicPackages(): { dir: string, pkg: Manifest }[] {
56 const packages: { dir: string, pkg: Manifest }[] = []
57 // Match Pantry's monorepo publish scope: packages/, excluding private entries.
58 // Other workspaces such as benchmarks are development tools, not npm products.
59 for (const path of new Bun.Glob('packages/*/package.json').scanSync({ cwd: root })) {
60 const pkg: Manifest = JSON.parse(readFileSync(join(root, path), 'utf8'))
61 if (!pkg.private)
62 packages.push({ dir: dirname(join(root, path)), pkg })
63 }
64 // The SDK generates the shared declarations used by package consumers.
65 return packages.sort((a, b) => a.pkg.name === 'craft-native' ? -1 : b.pkg.name === 'craft-native' ? 1 : a.pkg.name.localeCompare(b.pkg.name))
66}
67
68function importSpecifiers(pkg: Manifest): string[] {
69 if (!pkg.exports)
70 return pkg.main || pkg.module ? [pkg.name] : []
71 if (typeof pkg.exports === 'object' && pkg.exports !== null) {
72 const keys = Object.keys(pkg.exports)
73 if (keys.some(key => key.startsWith('.')))
74 return keys.filter(key => key !== './package.json').map(key => key === '.' ? pkg.name : `${pkg.name}${key.slice(1)}`)
75 }
76 return [pkg.name]
77}
78
79export async function verifyNpmPackages(buildOnly = false): Promise<void> {
80 const packages = publicPackages()
81 if (packages.length === 0)
82 throw new Error('No public npm workspaces found')
83 const temp = mkdtempSync(join(tmpdir(), 'craft-npm-verify-'))
84 try {
85 for (const { dir, pkg } of packages) {
86 if (pkg.scripts?.build) {
87 console.log(`Building ${pkg.name}`)
88 // Generated outputs must come from this build, not a previous checkout.
89 rmSync(join(dir, 'dist'), { recursive: true, force: true })
90 run([process.execPath, 'run', 'build'], dir)
91 }
92 }
93
94 if (buildOnly)
95 return
96
97 const specifiers: string[] = []
98 for (const [index, { dir, pkg }] of packages.entries()) {
99 const archive = join(temp, `${index}.tgz`)
100 run([process.execPath, 'pm', 'pack', '--filename', archive], dir)
101 const entries = run(['tar', '-tzf', archive], temp).trim().split('\n')
102 const packed: Manifest = JSON.parse(run(['tar', '-xOzf', archive, 'package/package.json'], temp))
103 verifyArchiveTargets(packed, entries)
104 const installed = join(temp, 'node_modules', pkg.name)
105 mkdirSync(installed, { recursive: true })
106 run(['tar', '-xzf', archive, '--strip-components=1', '-C', installed], temp)
107 specifiers.push(...importSpecifiers(packed))
108 console.log(`Verified ${pkg.name}: ${manifestTargets(packed).length} packed targets`)
109 }
110
111 // Supply only declared external peers. Craft packages themselves must resolve
112 // to the extracted archives, never to workspace symlinks or tsconfig paths.
113 for (const { dir, pkg } of packages) {
114 for (const peer of Object.keys(pkg.peerDependencies ?? {})) {
115 const destination = join(temp, 'node_modules', peer)
116 if (existsSync(destination))
117 continue
118 let source = join(dir, 'node_modules', peer)
119 if (!existsSync(source)) source = join(root, 'node_modules', peer)
120 if (!existsSync(source)) throw new Error(`${pkg.name}: missing installed peer ${peer}`)
121 mkdirSync(dirname(destination), { recursive: true })
122 symlinkSync(source, destination, 'dir')
123 }
124 }
125 const runtimeImports = specifiers.map(specifier => `await import(${JSON.stringify(specifier)})`).join('\n')
126 writeFileSync(join(temp, 'runtime.mjs'), runtimeImports)
127 run([process.execPath, 'runtime.mjs'], temp)
128 const typeImports = specifiers.map((specifier, index) => `import * as entry${index} from ${JSON.stringify(specifier)}; void entry${index}`).join('\n')
129 writeFileSync(join(temp, 'consumer.mts'), typeImports)
130 run([
131 process.execPath, join(root, 'packages/typescript/scripts/tsc.ts'), 'consumer.mts',
132 '--noEmit', '--strict', '--module', 'esnext', '--moduleResolution', 'bundler',
133 '--target', 'esnext', '--skipLibCheck', '--ignoreConfig',
134 '--typeRoots', join(root, 'node_modules/@types'),
135 ], temp)
136 console.log(`Verified ${packages.length} npm archives and ${specifiers.length} installed import/type entry points.`)
137 }
138 finally {
139 rmSync(temp, { recursive: true, force: true })
140 }
141}
142
143if (import.meta.main)
144 await verifyNpmPackages(Bun.argv.includes('--build-only'))
scripts/svelte-package.test.tsadded+33-0
Changes to scripts/svelte-package.test.ts
@@ -0,0 +1,33 @@
1import { expect, test } from 'bun:test'
2import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'
3import { tmpdir } from 'node:os'
4import { join, resolve } from 'node:path'
5
6test('packed Svelte condition resolves outside the workspace', async () => {
7 const root = resolve(import.meta.dir, '..')
8 const temp = mkdtempSync(join(tmpdir(), 'craft-svelte-package-'))
9 try {
10 const packed = Bun.spawnSync([
11 process.execPath, 'pm', 'pack', '--filename', join(temp, 'package.tgz'),
12 ], { cwd: join(root, 'packages/svelte'), stdout: 'pipe', stderr: 'pipe' })
13 expect(packed.exitCode).toBe(0)
14 const installed = join(temp, 'node_modules/@craft-native/svelte')
15 mkdirSync(installed, { recursive: true })
16 const extracted = Bun.spawnSync([
17 'tar', '-xzf', join(temp, 'package.tgz'), '--strip-components=1', '-C', installed,
18 ], { stdout: 'pipe', stderr: 'pipe' })
19 expect(extracted.exitCode).toBe(0)
20 writeFileSync(join(temp, 'consumer.ts'), "export * from '@craft-native/svelte'\n")
21 const bundled = await Bun.build({
22 entrypoints: [join(temp, 'consumer.ts')],
23 conditions: ['svelte'],
24 external: ['svelte', 'svelte/*', 'craft-native'],
25 target: 'browser',
26 })
27 expect(bundled.logs.filter(log => log.level === 'error')).toEqual([])
28 expect(bundled.success).toBe(true)
29 }
30 finally {
31 rmSync(temp, { recursive: true, force: true })
32 }
33})