also looking at this
fix(release): build and verify all npm package artifacts
#289
11 files
+290
-3
| @@ -320,6 +320,12 @@ jobs: | ||
| 320 | 320 | - name: Verify publish surfaces |
| 321 | 321 | run: bun run verify:packages |
| 322 | 322 | |
| 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 | ||
| 323 | 329 | - name: Run root verification |
| 324 | 330 | run: bun run verify |
| 325 | 331 | |
| @@ -430,6 +430,12 @@ jobs: | ||
| 430 | 430 | - name: Install Dependencies |
| 431 | 431 | run: bun install |
| 432 | 432 | |
| 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 | ||
| 433 | 439 | - name: Publish to npm |
| 434 | 440 | run: pantry publish --npm --access public |
| 435 | 441 | env: |
| @@ -25,10 +25,13 @@ | ||
| 25 | 25 | "build:desktop": "bun run build:core", |
| 26 | 26 | "build:mobile": "bun run build:ios && bun run build:android", |
| 27 | 27 | "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", | |
| 29 | 30 | "build:check": "bun scripts/verify-builds.ts", |
| 30 | 31 | "verify": "bun scripts/verify.ts", |
| 31 | 32 | "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", | |
| 32 | 35 | "verify:create": "bun scripts/verify-create-craft.ts", |
| 33 | 36 | "verify:cimports": "bun scripts/audit-cimports.ts", |
| 34 | 37 | "verify:docs": "bun scripts/verify-doc-links.ts", |
| @@ -2,7 +2,11 @@ | ||
| 2 | 2 | * Build script for @craft-native/android |
| 3 | 3 | */ |
| 4 | 4 | |
| 5 | await Bun.build({ | |
| 5 | import { rm } from 'node:fs/promises' | |
| 6 | ||
| 7 | await rm('./dist', { recursive: true, force: true }) | |
| 8 | ||
| 9 | const result = await Bun.build({ | |
| 6 | 10 | entrypoints: ['./src/index.ts'], |
| 7 | 11 | outdir: './dist', |
| 8 | 12 | target: 'bun', |
| @@ -11,4 +15,10 @@ await Bun.build({ | ||
| 11 | 15 | sourcemap: 'external', |
| 12 | 16 | }) |
| 13 | 17 | |
| 18 | if (!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. | |
| 22 | await Bun.$`bun ../typescript/scripts/tsc.ts src/index.ts --declaration --emitDeclarationOnly --module esnext --target esnext --moduleResolution bundler --types bun --skipLibCheck --ignoreConfig --outDir dist` | |
| 23 | ||
| 14 | 24 | console.log('✅ Build complete') |
| @@ -1,8 +1,13 @@ | ||
| 1 | import { rm } from 'node:fs/promises' | |
| 1 | 2 | import { $ } from 'bun' |
| 2 | 3 | |
| 4 | await rm('./dist', { recursive: true, force: true }) | |
| 5 | ||
| 3 | 6 | // Build TypeScript |
| 4 | 7 | await $`bun build src/index.ts --outdir dist --target bun` |
| 5 | 8 | await $`bun build src/cli.ts --outdir dist --target bun` |
| 9 | // The standalone package advertises this declaration independently of the SDK. | |
| 10 | await $`bun ../typescript/scripts/tsc.ts src/index.ts --declaration --emitDeclarationOnly --module esnext --target esnext --moduleResolution bundler --types bun --skipLibCheck --ignoreConfig --outDir dist` | |
| 6 | 11 | |
| 7 | 12 | // Make CLI executable |
| 8 | 13 | await $`chmod +x dist/cli.js` |
| @@ -8,6 +8,10 @@ Svelte bindings for the Craft framework using stores and actions. | ||
| 8 | 8 | npm install @craft-native/svelte |
| 9 | 9 | ``` |
| 10 | 10 | |
| 11 | The package includes its TypeScript source for bundlers that select the `svelte` | |
| 12 | export condition. Other consumers use the compiled ESM or CommonJS entry in | |
| 13 | `dist`. Both entries expose the same stores and actions. | |
| 14 | ||
| 11 | 15 | ## Usage |
| 12 | 16 | |
| 13 | 17 | ### Stores |
| @@ -0,0 +1,42 @@ | ||
| 1 | import { expect, test } from 'bun:test' | |
| 2 | import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' | |
| 3 | import { tmpdir } from 'node:os' | |
| 4 | import { join, resolve } from 'node:path' | |
| 5 | ||
| 6 | const root = resolve(import.meta.dir, '..') | |
| 7 | ||
| 8 | for (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'), ` | |
| 25 | import { init, type InitOptions } from '@craft-native/${platform}' | |
| 26 | const options: InitOptions = { name: 'Consumer', output: '/tmp/consumer', runtimeDir: null } | |
| 27 | const result: Promise<void> = init(options) | |
| 28 | // @ts-expect-error name must be a string, even outside the workspace | |
| 29 | init({ name: 42, output: '/tmp/consumer' }) | |
| 30 | void 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 | } | |
| @@ -0,0 +1,33 @@ | ||
| 1 | import { expect, test } from 'bun:test' | |
| 2 | import { manifestTargets, verifyArchiveTargets } from './npm-packages' | |
| 3 | ||
| 4 | test('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 | ||
| 10 | test('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 | ||
| 25 | test('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 | ||
| 30 | test('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 | }) | |
| @@ -0,0 +1,144 @@ | ||
| 1 | import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, symlinkSync, writeFileSync } from 'node:fs' | |
| 2 | import { tmpdir } from 'node:os' | |
| 3 | import { dirname, join, resolve } from 'node:path' | |
| 4 | ||
| 5 | type 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 | ||
| 18 | const root = resolve(import.meta.dir, '..') | |
| 19 | ||
| 20 | function 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 | ||
| 27 | export 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 | ||
| 47 | export 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 | ||
| 55 | function 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 | ||
| 68 | function 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 | ||
| 79 | export 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 | ||
| 143 | if (import.meta.main) | |
| 144 | await verifyNpmPackages(Bun.argv.includes('--build-only')) | |
| @@ -0,0 +1,33 @@ | ||
| 1 | import { expect, test } from 'bun:test' | |
| 2 | import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' | |
| 3 | import { tmpdir } from 'node:os' | |
| 4 | import { join, resolve } from 'node:path' | |
| 5 | ||
| 6 | test('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 | }) | |