From 44dcaae334f9b6ce14fde3b9eeeefc11ca02f60e Mon Sep 17 00:00:00 2001 From: Christoph Pader Date: Fri, 18 Sep 2026 15:41:45 +0200 Subject: [PATCH] fix(release): validate lockfile before publishing --- .github/workflows/release.yml | 5 ++ bun.lock | 9 ++- package.json | 5 +- scripts/bun-lockfile-workspaces.ts | 98 +++++++++++++++++++++++++ scripts/check-bun-lockfile.ts | 114 ++++------------------------- scripts/release.sh | 3 + scripts/release.test.ts | 89 ++++++++++++++++++++++ scripts/sync-bun-lockfile.test.ts | 98 +++++++++++++++++++++++++ scripts/sync-bun-lockfile.ts | 103 ++++++++++++++++++++++++++ 9 files changed, 422 insertions(+), 102 deletions(-) create mode 100644 scripts/bun-lockfile-workspaces.ts create mode 100644 scripts/release.test.ts create mode 100644 scripts/sync-bun-lockfile.test.ts create mode 100644 scripts/sync-bun-lockfile.ts diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 261ed72b..5dc854fb 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -35,6 +35,8 @@ jobs: package-manager-cache: false - uses: oven-sh/setup-bun@v2 + with: + bun-version: 1.3.1 - name: Setup Ruby (bundle) uses: ruby/setup-ruby@v1 @@ -49,6 +51,9 @@ jobs: - name: Install npm dependencies (bun) run: bun install + - name: Test release preflight + run: bun run test:release + - name: Configure Git author run: | git config user.name "github-actions[bot]" diff --git a/bun.lock b/bun.lock index cca0dc89..6b4262ae 100644 --- a/bun.lock +++ b/bun.lock @@ -35,6 +35,7 @@ "eslint-config-prettier": "^10.1.8", "eslint-plugin-prettier": "^5.5.4", "jest": "^30.2.0", + "jsonc-parser": "^3.3.1", "nitrogen": "0.37.1", "prettier": "^3.6.2", "release-it": "^19.2.4", @@ -58,8 +59,8 @@ "react": "19.2.3", "react-native": "0.87.1", "react-native-nitro-modules": "*", - "react-native-nitro-sqlite": "9.8.1", - "react-native-nitro-sqlite-vec": "9.8.1", + "react-native-nitro-sqlite": "9.8.2", + "react-native-nitro-sqlite-vec": "9.8.2", "react-native-quick-base64": "^3.0.0", "react-native-quick-crypto": "^1.1.5", "react-native-safe-area-context": "^5.10.0", @@ -122,7 +123,7 @@ "name": "react-native-nitro-sqlite-vec", "version": "9.8.2", "devDependencies": { - "react-native-nitro-sqlite": "9.8.1", + "react-native-nitro-sqlite": "9.8.2", "typescript": "^5.8.3", }, "peerDependencies": { @@ -1743,6 +1744,8 @@ "json5": ["json5@2.2.3", "", { "bin": { "json5": "lib/cli.js" } }, "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg=="], + "jsonc-parser": ["jsonc-parser@3.3.1", "", {}, "sha512-HUgH65KyejrUFPvHFPbqOY0rsFip3Bo5wb4ngvdi1EpCYWUQDC5V+Y7mZws+DLkr4M//zQJoanu1SP+87Dv1oQ=="], + "jsonfile": ["jsonfile@4.0.0", "", { "optionalDependencies": { "graceful-fs": "^4.1.6" } }, "sha512-m6F1R3z8jjlf2imQHS2Qez5sjKWQzbuuhuJ/FKYFRZvPE3PuHcSMVZzfsLhGVOkfd20obL5SWEBew5ShlquNxg=="], "jsonify": ["jsonify@0.0.1", "", {}, "sha512-2/Ki0GcmuqSrgFyelQq9M05y7PS0mEwuIzrf3f1fPqkVDVRvZrPZtVSMHxdgo8Aq0sxAOb/cr2aqqA3LeWHVPg=="], diff --git a/package.json b/package.json index d25577d7..a82604af 100644 --- a/package.json +++ b/package.json @@ -25,6 +25,8 @@ "prettier": "prettier --write .", "clean": "rm -rf **/.tsbuild **/tsconfig.tsbuildinfo node_modules packages/react-native-nitro-sqlite/node_module packages/react-native-nitro-sqlite/lib", "check:lockfile": "bun scripts/check-bun-lockfile.ts", + "sync:lockfile": "bun scripts/sync-bun-lockfile.ts", + "test:release": "bun test scripts/sync-bun-lockfile.test.ts scripts/release.test.ts", "release": "./scripts/release.sh", "sqlite": "bun --cwd packages/react-native-nitro-sqlite", "example": "bun --cwd example" @@ -63,6 +65,7 @@ "eslint-config-prettier": "^10.1.8", "eslint-plugin-prettier": "^5.5.4", "jest": "^30.2.0", + "jsonc-parser": "^3.3.1", "nitrogen": "0.37.1", "prettier": "^3.6.2", "release-it": "^19.2.4", @@ -86,7 +89,7 @@ } }, "hooks": { - "before:release": "bun install --lockfile-only && bun run check:lockfile && bun example bundle-install && bun example pods && git add bun.lock example/ios/Podfile.lock" + "before:release": "bun install --lockfile-only && bun run sync:lockfile && bun run check:lockfile && bun example bundle-install && bun example pods && git add bun.lock example/ios/Podfile.lock" }, "plugins": { "@release-it/bumper": { diff --git a/scripts/bun-lockfile-workspaces.ts b/scripts/bun-lockfile-workspaces.ts new file mode 100644 index 00000000..c84b31ee --- /dev/null +++ b/scripts/bun-lockfile-workspaces.ts @@ -0,0 +1,98 @@ +import { existsSync, readFileSync, readdirSync } from 'node:fs' +import { join } from 'node:path' +import ts from 'typescript' + +export const dependencyFields = [ + 'dependencies', + 'devDependencies', + 'optionalDependencies', + 'peerDependencies', +] + +export function getWorkspacePaths( + root: string, + manifest: Record, +): string[] { + const patterns = manifest.workspaces + if (!Array.isArray(patterns)) { + throw new Error('package.json workspaces must be a list of paths') + } + + const workspacePatterns: string[] = [] + for (const pattern of patterns) { + if (typeof pattern !== 'string') { + throw new Error('package.json workspaces must be a list of paths') + } + workspacePatterns.push(pattern) + } + + return workspacePatterns.flatMap((pattern) => { + if (!pattern.includes('*')) { + return [pattern] + } + if (!pattern.endsWith('/*') || pattern.slice(0, -2).includes('*')) { + throw new Error(`Unsupported workspace pattern: ${pattern}`) + } + + const parent = pattern.slice(0, -2) + return readdirSync(join(root, parent), { withFileTypes: true }) + .filter( + (entry) => + entry.isDirectory() && + existsSync(join(root, parent, entry.name, 'package.json')), + ) + .map((entry) => join(parent, entry.name)) + }) +} + +export function readManifest( + root: string, + path: string, +): Record { + const value: unknown = JSON.parse(readFileSync(join(root, path), 'utf8')) + return requireRecord(value, path) +} + +export function readLockfile(root: string): Record { + const { config, error } = ts.parseConfigFileTextToJson( + 'bun.lock', + readFileSync(join(root, 'bun.lock'), 'utf8'), + ) + if (error) { + throw new Error(ts.flattenDiagnosticMessageText(error.messageText, '\n')) + } + return requireRecord(config, 'bun.lock') +} + +export function readDependencies( + value: unknown, + label: string, +): Record { + if (value === undefined) { + return {} + } + + const dependencies = requireRecord(value, label) + const versions: Record = {} + for (const [name, version] of Object.entries(dependencies)) { + if (typeof version !== 'string') { + throw new Error(`${label}.${name} must be a string`) + } + versions[name] = version + } + return versions +} + +export function requireRecord( + value: unknown, + label: string, +): Record { + if (!isRecord(value)) { + throw new Error(`${label} must be an object`) + } + return value +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value) +} diff --git a/scripts/check-bun-lockfile.ts b/scripts/check-bun-lockfile.ts index 2cc88b97..275438ca 100644 --- a/scripts/check-bun-lockfile.ts +++ b/scripts/check-bun-lockfile.ts @@ -1,26 +1,23 @@ -import { existsSync, readFileSync, readdirSync } from 'node:fs' -import { join } from 'node:path' import { fileURLToPath } from 'node:url' -import ts from 'typescript' - -const root = fileURLToPath(new URL('../', import.meta.url)) -const dependencyFields = [ - 'dependencies', - 'devDependencies', - 'optionalDependencies', - 'peerDependencies', -] - -checkLockfile() - -function checkLockfile(): void { - const manifest = readManifest('package.json') - const lockfile = readLockfile() +import { + dependencyFields, + getWorkspacePaths, + readDependencies, + readLockfile, + readManifest, + requireRecord, +} from './bun-lockfile-workspaces' + +checkLockfile(process.argv[2] ?? fileURLToPath(new URL('../', import.meta.url))) + +function checkLockfile(root: string): void { + const manifest = readManifest(root, 'package.json') + const lockfile = readLockfile(root) const lockWorkspaces = requireRecord( lockfile.workspaces, 'bun.lock workspaces', ) - const expectedPaths = new Set(['', ...getWorkspacePaths(manifest)]) + const expectedPaths = new Set(['', ...getWorkspacePaths(root, manifest)]) const errors: string[] = [] for (const path of expectedPaths) { @@ -30,7 +27,7 @@ function checkLockfile(): void { `bun.lock workspace ${label}`, ) const workspaceManifest = path - ? readManifest(join(path, 'package.json')) + ? readManifest(root, `${path}/package.json`) : manifest compareField({ @@ -106,82 +103,3 @@ function compareField({ ) } } - -function getWorkspacePaths(manifest: Record): string[] { - const patterns = manifest.workspaces - if (!Array.isArray(patterns)) { - throw new Error('package.json workspaces must be a list of paths') - } - - const workspacePatterns: string[] = [] - for (const pattern of patterns) { - if (typeof pattern !== 'string') { - throw new Error('package.json workspaces must be a list of paths') - } - workspacePatterns.push(pattern) - } - - return workspacePatterns.flatMap((pattern) => { - if (!pattern.includes('*')) { - return [pattern] - } - if (!pattern.endsWith('/*') || pattern.slice(0, -2).includes('*')) { - throw new Error(`Unsupported workspace pattern: ${pattern}`) - } - - const parent = pattern.slice(0, -2) - return readdirSync(join(root, parent), { withFileTypes: true }) - .filter( - (entry) => - entry.isDirectory() && - existsSync(join(root, parent, entry.name, 'package.json')), - ) - .map((entry) => join(parent, entry.name)) - }) -} - -function readManifest(path: string): Record { - const value: unknown = JSON.parse(readFileSync(join(root, path), 'utf8')) - return requireRecord(value, path) -} - -function readLockfile(): Record { - const { config, error } = ts.parseConfigFileTextToJson( - 'bun.lock', - readFileSync(join(root, 'bun.lock'), 'utf8'), - ) - if (error) { - throw new Error(ts.flattenDiagnosticMessageText(error.messageText, '\n')) - } - return requireRecord(config, 'bun.lock') -} - -function readDependencies( - value: unknown, - label: string, -): Record { - if (value === undefined) { - return {} - } - - const dependencies = requireRecord(value, label) - const versions: Record = {} - for (const [name, version] of Object.entries(dependencies)) { - if (typeof version !== 'string') { - throw new Error(`${label}.${name} must be a string`) - } - versions[name] = version - } - return versions -} - -function requireRecord(value: unknown, label: string): Record { - if (!isRecord(value)) { - throw new Error(`${label} must be an object`) - } - return value -} - -function isRecord(value: unknown): value is Record { - return typeof value === 'object' && value !== null && !Array.isArray(value) -} diff --git a/scripts/release.sh b/scripts/release.sh index 3f66b25e..c4f92300 100755 --- a/scripts/release.sh +++ b/scripts/release.sh @@ -61,6 +61,9 @@ fi echo "Resolved release version: $release_version" +echo "Preparing and validating release versions before publishing" +bun run release-it "$release_version" "${forward_args[@]}" --ci --no-git --no-github + echo "Publishing react-native-nitro-sqlite@$release_version to NPM" cd packages/react-native-nitro-sqlite bun release "$release_version" "${package_args[@]}" diff --git a/scripts/release.test.ts b/scripts/release.test.ts new file mode 100644 index 00000000..2e591d5b --- /dev/null +++ b/scripts/release.test.ts @@ -0,0 +1,89 @@ +import assert from 'node:assert/strict' +import { spawnSync } from 'node:child_process' +import { + chmodSync, + mkdtempSync, + readFileSync, + rmSync, + writeFileSync, +} from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import test from 'node:test' +import { fileURLToPath } from 'node:url' + +const projectRoot = fileURLToPath(new URL('../', import.meta.url)) + +test('preflight failure stops the release before either package publishes', () => { + const result = runRelease(true) + + assert.notEqual(result.status, 0) + assert.ok( + result.calls.includes('run release-it --increment patch --release-version'), + ) + assert.ok( + result.calls.includes('run release-it 9.8.3 --ci --no-git --no-github'), + ) + assert.ok(!result.calls.includes('release 9.8.3')) +}) + +test('preflight runs before package publication and the final Git release', () => { + const result = runRelease(false) + + assert.equal(result.status, 0) + assert.deepEqual(result.calls, [ + 'run check:lockfile', + 'run release-it --increment patch --release-version', + 'run release-it 9.8.3 --ci --no-git --no-github', + 'release 9.8.3', + 'release 9.8.3', + 'run release-it 9.8.3', + ]) +}) + +function runRelease(failPreflight: boolean): { + status: number | null + calls: string[] +} { + const fixture = mkdtempSync(join(tmpdir(), 'nitro-release-')) + const logPath = join(fixture, 'calls.txt') + const bunStub = join(fixture, 'bun') + + try { + writeFileSync( + bunStub, + `#!/usr/bin/env bash +printf '%s\\n' "$*" >> "$RELEASE_TEST_LOG" +if [[ "$*" == *"--release-version"* ]]; then + echo 9.8.3 +fi +if [[ "$*" == *"--no-git --no-github"* && "$RELEASE_TEST_FAIL_PREFLIGHT" == 1 ]]; then + exit 1 +fi +`, + ) + chmodSync(bunStub, 0o755) + + const result = spawnSync( + 'bash', + ['./scripts/release.sh', '--increment', 'patch'], + { + cwd: projectRoot, + encoding: 'utf8', + env: { + ...process.env, + PATH: `${fixture}:${process.env.PATH ?? ''}`, + RELEASE_TEST_LOG: logPath, + RELEASE_TEST_FAIL_PREFLIGHT: failPreflight ? '1' : '0', + }, + }, + ) + + return { + status: result.status, + calls: readFileSync(logPath, 'utf8').trim().split('\n'), + } + } finally { + rmSync(fixture, { recursive: true, force: true }) + } +} diff --git a/scripts/sync-bun-lockfile.test.ts b/scripts/sync-bun-lockfile.test.ts new file mode 100644 index 00000000..13d188d4 --- /dev/null +++ b/scripts/sync-bun-lockfile.test.ts @@ -0,0 +1,98 @@ +import assert from 'node:assert/strict' +import { spawnSync } from 'node:child_process' +import { + mkdirSync, + mkdtempSync, + readFileSync, + rmSync, + writeFileSync, +} from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import test from 'node:test' +import { fileURLToPath } from 'node:url' +import { parse } from 'jsonc-parser' + +const projectRoot = fileURLToPath(new URL('../', import.meta.url)) + +test('synchronizes stale workspace versions and dependency references', () => { + const fixture = mkdtempSync(join(tmpdir(), 'nitro-lockfile-')) + + try { + mkdirSync(join(fixture, 'example')) + mkdirSync(join(fixture, 'packages', 'core'), { recursive: true }) + mkdirSync(join(fixture, 'packages', 'vec')) + writeJson('package.json', { + name: 'workspace', + workspaces: ['example', 'packages/*'], + }) + writeJson('example/package.json', { + name: 'example', + version: '9.8.2', + dependencies: { core: '9.8.2', vec: '9.8.2' }, + }) + writeJson('packages/core/package.json', { + name: 'core', + version: '9.8.2', + }) + writeJson('packages/vec/package.json', { + name: 'vec', + version: '9.8.2', + devDependencies: { core: '9.8.2' }, + }) + writeFileSync( + join(fixture, 'bun.lock'), + `{ + "workspaces": { + "": { "name": "workspace" }, + "example": { "name": "example", "version": "9.8.1", "dependencies": { "core": "9.8.1", "vec": "9.8.1" } }, + "packages/core": { "name": "core", "version": "9.8.1" }, + "packages/vec": { "name": "vec", "version": "9.8.1", "devDependencies": { "core": "9.8.1" } }, + }, +} +`, + ) + + assert.notEqual(run('check-bun-lockfile.ts').status, 0) + assert.equal(run('sync-bun-lockfile.ts').status, 0) + assert.equal(run('check-bun-lockfile.ts').status, 0) + + const synchronized = readFileSync(join(fixture, 'bun.lock'), 'utf8') + const lockfile = parse(synchronized) + assert.equal(lockfile.workspaces.example.version, '9.8.2') + assert.deepEqual(lockfile.workspaces.example.dependencies, { + core: '9.8.2', + vec: '9.8.2', + }) + assert.equal( + lockfile.workspaces['packages/vec'].devDependencies.core, + '9.8.2', + ) + + assert.equal(run('sync-bun-lockfile.ts').status, 0) + assert.equal(readFileSync(join(fixture, 'bun.lock'), 'utf8'), synchronized) + + writeJson('example/package.json', { + name: 'example', + version: '9.8.2', + dependencies: { core: '9.8.2', vec: '9.8.2', external: '2.0.0' }, + }) + assert.equal(run('sync-bun-lockfile.ts').status, 0) + assert.equal(readFileSync(join(fixture, 'bun.lock'), 'utf8'), synchronized) + assert.notEqual(run('check-bun-lockfile.ts').status, 0) + } finally { + rmSync(fixture, { recursive: true, force: true }) + } + + function writeJson(path: string, value: object): void { + writeFileSync(join(fixture, path), JSON.stringify(value)) + } + + function run(script: string): ReturnType { + return spawnSync( + process.execPath, + [join(projectRoot, 'scripts', script), fixture], + { encoding: 'utf8' }, + ) + } +}) diff --git a/scripts/sync-bun-lockfile.ts b/scripts/sync-bun-lockfile.ts new file mode 100644 index 00000000..2accd2bb --- /dev/null +++ b/scripts/sync-bun-lockfile.ts @@ -0,0 +1,103 @@ +import { readFileSync, writeFileSync } from 'node:fs' +import { join } from 'node:path' +import { fileURLToPath } from 'node:url' +import { applyEdits, modify, parse, type ParseError } from 'jsonc-parser' +import { + dependencyFields, + getWorkspacePaths, + readDependencies, + readManifest, + requireRecord, +} from './bun-lockfile-workspaces' + +syncLockfile(process.argv[2] ?? fileURLToPath(new URL('../', import.meta.url))) + +function syncLockfile(root: string): void { + const lockfilePath = join(root, 'bun.lock') + let content = readFileSync(lockfilePath, 'utf8') + const originalContent = content + const parseErrors: ParseError[] = [] + const parsed: unknown = parse(content, parseErrors, { + allowTrailingComma: true, + }) + if (parseErrors.length > 0) { + throw new Error('Cannot synchronize an invalid bun.lock') + } + + const workspaces = requireRecord( + requireRecord(parsed, 'bun.lock').workspaces, + 'bun.lock workspaces', + ) + const rootManifest = readManifest(root, 'package.json') + const paths = ['', ...getWorkspacePaths(root, rootManifest)] + const manifests = new Map( + paths.map((path) => [ + path, + path ? readManifest(root, `${path}/package.json`) : rootManifest, + ]), + ) + const workspaceNames = new Set() + for (const path of paths) { + if (!path) { + continue + } + const name = requireRecord(manifests.get(path), path).name + if (typeof name !== 'string') { + throw new Error(`${path} name must be a string`) + } + workspaceNames.add(name) + } + + for (const path of paths) { + const label = path || 'root' + const workspace = requireRecord( + workspaces[path], + `bun.lock workspace ${label}`, + ) + const manifest = requireRecord(manifests.get(path), `${label} manifest`) + + if (path) { + update( + ['workspaces', path, 'version'], + workspace.version, + manifest.version, + ) + } + + for (const field of dependencyFields) { + const locked = readDependencies( + workspace[field], + `bun.lock ${label} ${field}`, + ) + const expected = readDependencies(manifest[field], `${label} ${field}`) + const names = new Set([...Object.keys(locked), ...Object.keys(expected)]) + + for (const name of names) { + if (!workspaceNames.has(name)) { + continue + } + update(['workspaces', path, field, name], locked[name], expected[name]) + } + } + } + + if (content !== originalContent) { + writeFileSync(lockfilePath, content) + } + + function update(path: string[], current: unknown, expected: unknown): void { + if (current === expected) { + return + } + if (expected !== undefined && typeof expected !== 'string') { + throw new Error(`${path.join('.')} must be a string`) + } + + content = applyEdits( + content, + modify(content, path, expected, { + formattingOptions: { insertSpaces: true, tabSize: 2 }, + }), + ) + } +}