From 4a8e5bd8c5562cb004c231448676b4dcdc8ee857 Mon Sep 17 00:00:00 2001 From: owjs3901 Date: Mon, 24 Aug 2026 18:11:52 +0900 Subject: [PATCH 1/3] fix: materialize workspace dependencies before npm publish --- ...angepack_log_workspacePublish20260824.json | 17 ++ .changepacks/config.json | 4 +- .github/workflows/publish.yml | 11 +- bun.lock | 22 +-- package.json | 2 + publish-package.test.ts | 116 ++++++++++++ publish-package.ts | 178 ++++++++++++++++++ 7 files changed, 334 insertions(+), 16 deletions(-) create mode 100644 .changepacks/changepack_log_workspacePublish20260824.json create mode 100644 publish-package.test.ts create mode 100644 publish-package.ts diff --git a/.changepacks/changepack_log_workspacePublish20260824.json b/.changepacks/changepack_log_workspacePublish20260824.json new file mode 100644 index 000000000..030354471 --- /dev/null +++ b/.changepacks/changepack_log_workspacePublish20260824.json @@ -0,0 +1,17 @@ +{ + "changes": { + "bindings/devup-ui-wasm/package.json": "Patch", + "packages/bun-plugin/package.json": "Patch", + "packages/components/package.json": "Patch", + "packages/eslint-plugin/package.json": "Patch", + "packages/next-plugin/package.json": "Patch", + "packages/plugin-utils/package.json": "Patch", + "packages/react/package.json": "Patch", + "packages/reset-css/package.json": "Patch", + "packages/rsbuild-plugin/package.json": "Patch", + "packages/vite-plugin/package.json": "Patch", + "packages/webpack-plugin/package.json": "Patch" + }, + "note": "Publish all npm packages with materialized workspace dependency versions", + "date": "2026-08-24T09:05:00.000Z" +} diff --git a/.changepacks/config.json b/.changepacks/config.json index e614c9a1c..fe7a6270d 100644 --- a/.changepacks/config.json +++ b/.changepacks/config.json @@ -3,9 +3,9 @@ "baseBranch": "main", "latestPackage": null, "publish": { - "node": "npm publish" + "node": "bun ../../publish-package.ts" }, "publishDryRun": { - "node": "npm publish --dry-run" + "node": "bun ../../publish-package.ts --dry-run" } } diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 4ad419e36..a9b9cc6d7 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -183,6 +183,8 @@ jobs: - run: bun run build - name: Verify built package entry points run: bun run verify:dist + - name: Verify publish tarballs + run: bun run verify:publish - run: | bun run lint # rust coverage issue @@ -196,6 +198,8 @@ jobs: echo 'use_small_heuristics = "Default"' >> .rustfmt.toml cargo fmt - run: bun run test + - name: Test trusted publish pipeline + run: bun run test:publish - name: Regression test (bun-plugin preload race) run: bun run --filter @devup-ui/bun-plugin test:regression - name: Format Rollback @@ -269,9 +273,10 @@ jobs: # file `publish.yml`. npm matches that filename exactly, so renaming or # moving this file breaks publishing until the entries are updated. # - # The publish command itself is selected in .changepacks/config.json: - # changepacks defaults to `bun publish` because this repo has a bun.lock, - # and bun cannot do OIDC (oven-sh/bun#22423). + # The publish command itself is selected in .changepacks/config.json. + # The wrapper uses Bun to materialize workspace dependency ranges into a + # tarball, verifies none remain, then gives that tarball to npm for OIDC + # trusted publishing (Bun itself cannot do OIDC: oven-sh/bun#22423). - uses: changepacks/action@main id: changepacks with: diff --git a/bun.lock b/bun.lock index 2a17f5b1f..13270b5f1 100644 --- a/bun.lock +++ b/bun.lock @@ -428,11 +428,11 @@ }, "bindings/devup-ui-wasm": { "name": "@devup-ui/wasm", - "version": "1.0.75", + "version": "1.0.77", }, "packages/bun-plugin": { "name": "@devup-ui/bun-plugin", - "version": "1.0.12", + "version": "1.0.14", "dependencies": { "@devup-ui/plugin-utils": "workspace:^", "@devup-ui/wasm": "workspace:^", @@ -447,7 +447,7 @@ }, "packages/components": { "name": "@devup-ui/components", - "version": "0.1.50", + "version": "0.1.52", "dependencies": { "@devup-ui/react": "workspace:^", "clsx": "^2.1", @@ -474,7 +474,7 @@ }, "packages/eslint-plugin": { "name": "@devup-ui/eslint-plugin", - "version": "1.0.16", + "version": "1.0.17", "dependencies": { "@typescript-eslint/utils": "^8.67", "typescript-eslint": "^8.67", @@ -490,7 +490,7 @@ }, "packages/next-plugin": { "name": "@devup-ui/next-plugin", - "version": "1.0.80", + "version": "1.0.82", "dependencies": { "@devup-ui/plugin-utils": "workspace:^", "@devup-ui/wasm": "workspace:^", @@ -508,14 +508,14 @@ }, "packages/plugin-utils": { "name": "@devup-ui/plugin-utils", - "version": "1.0.10", + "version": "1.0.12", "devDependencies": { "typescript": "^7.0", }, }, "packages/react": { "name": "@devup-ui/react", - "version": "1.0.37", + "version": "1.0.39", "dependencies": { "csstype-extra": "latest", "react": "^19.2", @@ -536,7 +536,7 @@ }, "packages/reset-css": { "name": "@devup-ui/reset-css", - "version": "1.0.24", + "version": "1.0.26", "dependencies": { "@devup-ui/react": "workspace:^", }, @@ -549,7 +549,7 @@ }, "packages/rsbuild-plugin": { "name": "@devup-ui/rsbuild-plugin", - "version": "1.0.58", + "version": "1.0.60", "dependencies": { "@devup-ui/plugin-utils": "workspace:^", "@devup-ui/wasm": "workspace:^", @@ -565,7 +565,7 @@ }, "packages/vite-plugin": { "name": "@devup-ui/vite-plugin", - "version": "1.0.64", + "version": "1.0.66", "dependencies": { "@devup-ui/plugin-utils": "workspace:^", "@devup-ui/wasm": "workspace:^", @@ -580,7 +580,7 @@ }, "packages/webpack-plugin": { "name": "@devup-ui/webpack-plugin", - "version": "1.0.63", + "version": "1.0.65", "dependencies": { "@devup-ui/plugin-utils": "workspace:^", "@devup-ui/wasm": "workspace:^", diff --git a/package.json b/package.json index 63345e030..4ca39cf1c 100644 --- a/package.json +++ b/package.json @@ -8,6 +8,7 @@ "lint": "cargo fmt --all -- --check && cargo clippy --all-targets --all-features -- -D warnings && eslint", "lint:fix": "eslint --fix && cargo fmt", "test": "cargo tarpaulin --out xml --out stdout --all-targets --engine llvm $(node -p \"process.env.CI ? '--fail-under 100' : ''\") && bun test", + "test:publish": "bun test ./publish-package.test.ts", "test:e2e": "bunx playwright test", "test:e2e:next": "LANDING_BUILD_MODE=next bunx playwright test", "test:e2e:ui": "bunx playwright test --ui", @@ -15,6 +16,7 @@ "build": "bun run --filter @devup-ui/wasm --filter @devup-ui/plugin-utils build && bun run --filter @devup-ui/react --filter @devup-ui/webpack-plugin build && bun run --filter @devup-ui/eslint-plugin --filter @devup-ui/vite-plugin --filter @devup-ui/next-plugin --filter @devup-ui/rsbuild-plugin --filter @devup-ui/bun-plugin --filter @devup-ui/components --filter @devup-ui/reset-css build", "dev": "bun run --filter '*' dev", "verify:dist": "bun verify-dist.ts", + "verify:publish": "bun publish-package.ts --verify-workspace", "benchmark": "bun benchmark.js", "prepare": "husky", "changepacks": "bunx @changepacks/cli", diff --git a/publish-package.test.ts b/publish-package.test.ts new file mode 100644 index 000000000..231964131 --- /dev/null +++ b/publish-package.test.ts @@ -0,0 +1,116 @@ +import { mkdtemp, readdir, readFile, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join, resolve } from 'node:path' + +import { describe, expect, test } from 'bun:test' + +const rootDir = import.meta.dir +const publishScript = resolve(rootDir, 'publish-package.ts') + +async function run(args: string[], cwd: string) { + const process = Bun.spawn(args, { + cwd, + stdout: 'pipe', + stderr: 'pipe', + }) + const [exitCode, stdout, stderr] = await Promise.all([ + process.exited, + new Response(process.stdout).text(), + new Response(process.stderr).text(), + ]) + return { exitCode, stdout, stderr } +} + +async function readPackedManifest(tarball: string) { + const result = await run( + ['tar', '-xOf', tarball, 'package/package.json'], + rootDir, + ) + expect(result.exitCode).toBe(0) + return JSON.parse(result.stdout) as Record +} + +describe('trusted publish package', () => { + test('verify-workspace packs every public package without unresolved ranges', async () => { + const result = await run( + ['bun', publishScript, '--verify-workspace'], + rootDir, + ) + expect(result.exitCode).toBe(0) + }) + + test('pack-only materializes workspace ranges from current package versions', async () => { + const destination = await mkdtemp(join(tmpdir(), 'devup-ui-pack-test-')) + try { + const result = await run( + ['bun', publishScript, '--pack-only', destination], + resolve(rootDir, 'packages/components'), + ) + expect(result.exitCode).toBe(0) + + const tarballs = (await readdir(destination)).filter((file) => + file.endsWith('.tgz'), + ) + expect(tarballs).toHaveLength(1) + + const manifest = await readPackedManifest( + join(destination, tarballs[0] as string), + ) + const reactManifest = JSON.parse( + await readFile(resolve(rootDir, 'packages/react/package.json'), 'utf8'), + ) as { version: string } + const viteManifest = JSON.parse( + await readFile( + resolve(rootDir, 'packages/vite-plugin/package.json'), + 'utf8', + ), + ) as { version: string } + + expect(manifest.dependencies).toMatchObject({ + '@devup-ui/react': `^${reactManifest.version}`, + }) + expect(manifest.devDependencies).toMatchObject({ + '@devup-ui/vite-plugin': `^${viteManifest.version}`, + }) + expect(manifest.peerDependencies).toMatchObject({ + '@devup-ui/react': `^${reactManifest.version}`, + }) + expect(JSON.stringify(manifest)).not.toContain('workspace:') + } finally { + await rm(destination, { recursive: true, force: true }) + } + }) + + test('verify-tarball rejects unresolved workspace dependencies', async () => { + const fixtureDir = await mkdtemp(join(tmpdir(), 'devup-ui-invalid-pack-')) + try { + await Bun.write( + join(fixtureDir, 'package.json'), + JSON.stringify({ + name: 'invalid-workspace-package', + version: '1.0.0', + dependencies: { '@devup-ui/react': 'workspace:^' }, + }), + ) + const packed = await run( + ['npm', 'pack', '--json', '--pack-destination', fixtureDir], + fixtureDir, + ) + expect(packed.exitCode).toBe(0) + const [{ filename }] = JSON.parse(packed.stdout) as Array<{ + filename: string + }> + + const verified = await run( + ['bun', publishScript, '--verify-tarball', join(fixtureDir, filename)], + rootDir, + ) + expect(verified.exitCode).toBe(1) + expect(verified.stderr).toContain( + 'Unresolved workspace dependency: dependencies.@devup-ui/react', + ) + } finally { + await rm(fixtureDir, { recursive: true, force: true }) + } + }) +}) diff --git a/publish-package.ts b/publish-package.ts new file mode 100644 index 000000000..4acf11276 --- /dev/null +++ b/publish-package.ts @@ -0,0 +1,178 @@ +import { mkdir, mkdtemp, readdir, readFile, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { isAbsolute, join, resolve } from 'node:path' + +const DEPENDENCY_FIELDS = [ + 'dependencies', + 'devDependencies', + 'optionalDependencies', + 'peerDependencies', +] as const + +interface PackageManifest { + name?: string + version?: string + private?: boolean + dependencies?: Record + devDependencies?: Record + optionalDependencies?: Record + peerDependencies?: Record +} + +interface CommandResult { + exitCode: number + stdout: string + stderr: string +} + +async function runCaptured( + command: string[], + cwd: string, +): Promise { + const process = Bun.spawn(command, { + cwd, + env: globalThis.process.env, + stdout: 'pipe', + stderr: 'pipe', + }) + const [exitCode, stdout, stderr] = await Promise.all([ + process.exited, + new Response(process.stdout).text(), + new Response(process.stderr).text(), + ]) + return { exitCode, stdout, stderr } +} + +async function runOrThrow(command: string[], cwd: string): Promise { + const result = await runCaptured(command, cwd) + if (result.exitCode !== 0) { + throw new Error( + `${command.join(' ')} failed with exit code ${result.exitCode}\n${result.stderr}`, + ) + } + return result.stdout.trim() +} + +async function readPackedManifest(tarball: string): Promise { + const output = await runOrThrow( + ['tar', '-xOf', tarball, 'package/package.json'], + globalThis.process.cwd(), + ) + return JSON.parse(output) as PackageManifest +} + +async function verifyTarball(tarball: string): Promise { + const manifest = await readPackedManifest(tarball) + for (const field of DEPENDENCY_FIELDS) { + for (const [name, range] of Object.entries(manifest[field] ?? {})) { + if (range.startsWith('workspace:')) { + throw new Error(`Unresolved workspace dependency: ${field}.${name}`) + } + } + } +} + +async function packPackage( + packageDir: string, + destination: string, +): Promise { + await mkdir(destination, { recursive: true }) + const output = await runOrThrow( + ['bun', 'pm', 'pack', '--quiet', '--destination', destination], + packageDir, + ) + const packedPath = output + .split(/\r?\n/) + .findLast((line) => line.trim().endsWith('.tgz')) + ?.trim() + if (!packedPath) throw new Error('bun pm pack did not return a tarball path') + + const tarball = isAbsolute(packedPath) + ? packedPath + : resolve(packageDir, packedPath) + await verifyTarball(tarball) + return tarball +} + +async function getPublicPackageDirs(rootDir: string): Promise { + const packageDirs: string[] = [] + for (const parent of ['packages', 'bindings']) { + const parentDir = join(rootDir, parent) + const entries = await readdir(parentDir, { withFileTypes: true }) + for (const entry of entries) { + if (!entry.isDirectory()) continue + const packageDir = join(parentDir, entry.name) + const manifest = JSON.parse( + await readFile(join(packageDir, 'package.json'), 'utf8'), + ) as PackageManifest + if (manifest.private !== true) packageDirs.push(packageDir) + } + } + return packageDirs.sort() +} + +async function verifyWorkspace(rootDir: string): Promise { + const destination = await mkdtemp(join(tmpdir(), 'devup-ui-verify-')) + try { + for (const packageDir of await getPublicPackageDirs(rootDir)) { + await packPackage(packageDir, destination) + } + } finally { + await rm(destination, { recursive: true, force: true }) + } +} + +async function publishTarball(tarball: string, dryRun: boolean): Promise { + const command = ['npm', 'publish', tarball] + if (dryRun) command.push('--dry-run') + const process = Bun.spawn(command, { + cwd: globalThis.process.cwd(), + env: globalThis.process.env, + stdin: 'inherit', + stdout: 'inherit', + stderr: 'inherit', + }) + const exitCode = await process.exited + if (exitCode !== 0) { + throw new Error(`npm publish failed with exit code ${exitCode}`) + } +} + +async function main(): Promise { + const [mode, value] = Bun.argv.slice(2) + if (mode === '--verify-workspace') { + await verifyWorkspace(import.meta.dir) + return + } + + if (mode === '--verify-tarball') { + if (!value) throw new Error('--verify-tarball requires a tarball path') + await verifyTarball(resolve(value)) + return + } + + if (mode === '--pack-only') { + if (!value) throw new Error('--pack-only requires a destination') + await packPackage(globalThis.process.cwd(), resolve(value)) + return + } + + if (mode && mode !== '--dry-run') { + throw new Error(`Unknown argument: ${mode}`) + } + + const destination = await mkdtemp(join(tmpdir(), 'devup-ui-publish-')) + try { + const tarball = await packPackage(globalThis.process.cwd(), destination) + await publishTarball(tarball, mode === '--dry-run') + } finally { + await rm(destination, { recursive: true, force: true }) + } +} + +try { + await main() +} catch (error) { + console.error(error instanceof Error ? error.message : error) + globalThis.process.exitCode = 1 +} From 252b10bd79918cf588058aea9e06f3bf8dd0acf4 Mon Sep 17 00:00:00 2001 From: owjs3901 Date: Mon, 24 Aug 2026 18:22:02 +0900 Subject: [PATCH 2/3] test: cover every trusted publish package path --- publish-package.test.ts | 29 +++++++++++++++++++++++++++++ publish-package.ts | 5 ++++- 2 files changed, 33 insertions(+), 1 deletion(-) diff --git a/publish-package.test.ts b/publish-package.test.ts index 231964131..886c9e3d7 100644 --- a/publish-package.test.ts +++ b/publish-package.test.ts @@ -37,6 +37,35 @@ describe('trusted publish package', () => { rootDir, ) expect(result.exitCode).toBe(0) + expect(result.stdout.trim().split(/\r?\n/)).toEqual([ + 'verified bindings/devup-ui-wasm', + 'verified packages/bun-plugin', + 'verified packages/components', + 'verified packages/eslint-plugin', + 'verified packages/next-plugin', + 'verified packages/plugin-utils', + 'verified packages/react', + 'verified packages/reset-css', + 'verified packages/rsbuild-plugin', + 'verified packages/vite-plugin', + 'verified packages/webpack-plugin', + ]) + }) + + test('pack-only works from the bindings package directory', async () => { + const destination = await mkdtemp(join(tmpdir(), 'devup-ui-bindings-pack-')) + try { + const result = await run( + ['bun', publishScript, '--pack-only', destination], + resolve(rootDir, 'bindings/devup-ui-wasm'), + ) + expect(result.exitCode).toBe(0) + expect( + (await readdir(destination)).filter((file) => file.endsWith('.tgz')), + ).toHaveLength(1) + } finally { + await rm(destination, { recursive: true, force: true }) + } }) test('pack-only materializes workspace ranges from current package versions', async () => { diff --git a/publish-package.ts b/publish-package.ts index 4acf11276..0e1dbb3b4 100644 --- a/publish-package.ts +++ b/publish-package.ts @@ -1,6 +1,6 @@ import { mkdir, mkdtemp, readdir, readFile, rm } from 'node:fs/promises' import { tmpdir } from 'node:os' -import { isAbsolute, join, resolve } from 'node:path' +import { isAbsolute, join, relative, resolve } from 'node:path' const DEPENDENCY_FIELDS = [ 'dependencies', @@ -116,6 +116,9 @@ async function verifyWorkspace(rootDir: string): Promise { try { for (const packageDir of await getPublicPackageDirs(rootDir)) { await packPackage(packageDir, destination) + console.info( + `verified ${relative(rootDir, packageDir).replaceAll('\\', '/')}`, + ) } } finally { await rm(destination, { recursive: true, force: true }) From 45579f869569981b49eaab0388f39d3967a337bf Mon Sep 17 00:00:00 2001 From: owjs3901 Date: Mon, 24 Aug 2026 18:51:07 +0900 Subject: [PATCH 3/3] refactor: publish packed workspaces directly --- .changepacks/config.json | 4 +- .github/workflows/publish.yml | 28 ++++-- .gitignore | 1 + package.json | 2 - publish-package.test.ts | 145 --------------------------- publish-package.ts | 181 ---------------------------------- 6 files changed, 25 insertions(+), 336 deletions(-) delete mode 100644 publish-package.test.ts delete mode 100644 publish-package.ts diff --git a/.changepacks/config.json b/.changepacks/config.json index fe7a6270d..4d78153de 100644 --- a/.changepacks/config.json +++ b/.changepacks/config.json @@ -3,9 +3,9 @@ "baseBranch": "main", "latestPackage": null, "publish": { - "node": "bun ../../publish-package.ts" + "node": "bun pm pack --quiet --filename .changepacks/publish.tgz && npm publish ../../.changepacks/publish.tgz" }, "publishDryRun": { - "node": "bun ../../publish-package.ts --dry-run" + "node": "bun pm pack --quiet --filename .changepacks/publish.tgz && npm publish ../../.changepacks/publish.tgz --dry-run" } } diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index a9b9cc6d7..f55775097 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -184,7 +184,25 @@ jobs: - name: Verify built package entry points run: bun run verify:dist - name: Verify publish tarballs - run: bun run verify:publish + shell: bash + run: | + set -euo pipefail + for manifest in bindings/*/package.json packages/*/package.json; do + package_dir="$(dirname "$manifest")" + pack_dir="$RUNNER_TEMP/publish-verify/${package_dir//\//-}" + mkdir -p "$pack_dir" + (cd "$package_dir" && bun pm pack --quiet --destination "$pack_dir") + tarballs=("$pack_dir"/*.tgz) + if [[ "${#tarballs[@]}" -ne 1 ]]; then + echo "Expected one tarball for $package_dir" >&2 + exit 1 + fi + packed_manifest="$(tar -xOf "${tarballs[0]}" package/package.json)" + if grep -q 'workspace:' <<< "$packed_manifest"; then + echo "Unresolved workspace dependency in $package_dir" >&2 + exit 1 + fi + done - run: | bun run lint # rust coverage issue @@ -198,8 +216,6 @@ jobs: echo 'use_small_heuristics = "Default"' >> .rustfmt.toml cargo fmt - run: bun run test - - name: Test trusted publish pipeline - run: bun run test:publish - name: Regression test (bun-plugin preload race) run: bun run --filter @devup-ui/bun-plugin test:regression - name: Format Rollback @@ -274,9 +290,9 @@ jobs: # moving this file breaks publishing until the entries are updated. # # The publish command itself is selected in .changepacks/config.json. - # The wrapper uses Bun to materialize workspace dependency ranges into a - # tarball, verifies none remain, then gives that tarball to npm for OIDC - # trusted publishing (Bun itself cannot do OIDC: oven-sh/bun#22423). + # Bun packs each workspace so dependency ranges are materialized, then + # npm publishes that tarball with OIDC (Bun cannot do OIDC itself: + # oven-sh/bun#22423). - uses: changepacks/action@main id: changepacks with: diff --git a/.gitignore b/.gitignore index 91eb10b39..eb80866ac 100644 --- a/.gitignore +++ b/.gitignore @@ -23,3 +23,4 @@ playwright-report .omc .playwright-mcp .omo +/.changepacks/publish.tgz diff --git a/package.json b/package.json index 4ca39cf1c..63345e030 100644 --- a/package.json +++ b/package.json @@ -8,7 +8,6 @@ "lint": "cargo fmt --all -- --check && cargo clippy --all-targets --all-features -- -D warnings && eslint", "lint:fix": "eslint --fix && cargo fmt", "test": "cargo tarpaulin --out xml --out stdout --all-targets --engine llvm $(node -p \"process.env.CI ? '--fail-under 100' : ''\") && bun test", - "test:publish": "bun test ./publish-package.test.ts", "test:e2e": "bunx playwright test", "test:e2e:next": "LANDING_BUILD_MODE=next bunx playwright test", "test:e2e:ui": "bunx playwright test --ui", @@ -16,7 +15,6 @@ "build": "bun run --filter @devup-ui/wasm --filter @devup-ui/plugin-utils build && bun run --filter @devup-ui/react --filter @devup-ui/webpack-plugin build && bun run --filter @devup-ui/eslint-plugin --filter @devup-ui/vite-plugin --filter @devup-ui/next-plugin --filter @devup-ui/rsbuild-plugin --filter @devup-ui/bun-plugin --filter @devup-ui/components --filter @devup-ui/reset-css build", "dev": "bun run --filter '*' dev", "verify:dist": "bun verify-dist.ts", - "verify:publish": "bun publish-package.ts --verify-workspace", "benchmark": "bun benchmark.js", "prepare": "husky", "changepacks": "bunx @changepacks/cli", diff --git a/publish-package.test.ts b/publish-package.test.ts deleted file mode 100644 index 886c9e3d7..000000000 --- a/publish-package.test.ts +++ /dev/null @@ -1,145 +0,0 @@ -import { mkdtemp, readdir, readFile, rm } from 'node:fs/promises' -import { tmpdir } from 'node:os' -import { join, resolve } from 'node:path' - -import { describe, expect, test } from 'bun:test' - -const rootDir = import.meta.dir -const publishScript = resolve(rootDir, 'publish-package.ts') - -async function run(args: string[], cwd: string) { - const process = Bun.spawn(args, { - cwd, - stdout: 'pipe', - stderr: 'pipe', - }) - const [exitCode, stdout, stderr] = await Promise.all([ - process.exited, - new Response(process.stdout).text(), - new Response(process.stderr).text(), - ]) - return { exitCode, stdout, stderr } -} - -async function readPackedManifest(tarball: string) { - const result = await run( - ['tar', '-xOf', tarball, 'package/package.json'], - rootDir, - ) - expect(result.exitCode).toBe(0) - return JSON.parse(result.stdout) as Record -} - -describe('trusted publish package', () => { - test('verify-workspace packs every public package without unresolved ranges', async () => { - const result = await run( - ['bun', publishScript, '--verify-workspace'], - rootDir, - ) - expect(result.exitCode).toBe(0) - expect(result.stdout.trim().split(/\r?\n/)).toEqual([ - 'verified bindings/devup-ui-wasm', - 'verified packages/bun-plugin', - 'verified packages/components', - 'verified packages/eslint-plugin', - 'verified packages/next-plugin', - 'verified packages/plugin-utils', - 'verified packages/react', - 'verified packages/reset-css', - 'verified packages/rsbuild-plugin', - 'verified packages/vite-plugin', - 'verified packages/webpack-plugin', - ]) - }) - - test('pack-only works from the bindings package directory', async () => { - const destination = await mkdtemp(join(tmpdir(), 'devup-ui-bindings-pack-')) - try { - const result = await run( - ['bun', publishScript, '--pack-only', destination], - resolve(rootDir, 'bindings/devup-ui-wasm'), - ) - expect(result.exitCode).toBe(0) - expect( - (await readdir(destination)).filter((file) => file.endsWith('.tgz')), - ).toHaveLength(1) - } finally { - await rm(destination, { recursive: true, force: true }) - } - }) - - test('pack-only materializes workspace ranges from current package versions', async () => { - const destination = await mkdtemp(join(tmpdir(), 'devup-ui-pack-test-')) - try { - const result = await run( - ['bun', publishScript, '--pack-only', destination], - resolve(rootDir, 'packages/components'), - ) - expect(result.exitCode).toBe(0) - - const tarballs = (await readdir(destination)).filter((file) => - file.endsWith('.tgz'), - ) - expect(tarballs).toHaveLength(1) - - const manifest = await readPackedManifest( - join(destination, tarballs[0] as string), - ) - const reactManifest = JSON.parse( - await readFile(resolve(rootDir, 'packages/react/package.json'), 'utf8'), - ) as { version: string } - const viteManifest = JSON.parse( - await readFile( - resolve(rootDir, 'packages/vite-plugin/package.json'), - 'utf8', - ), - ) as { version: string } - - expect(manifest.dependencies).toMatchObject({ - '@devup-ui/react': `^${reactManifest.version}`, - }) - expect(manifest.devDependencies).toMatchObject({ - '@devup-ui/vite-plugin': `^${viteManifest.version}`, - }) - expect(manifest.peerDependencies).toMatchObject({ - '@devup-ui/react': `^${reactManifest.version}`, - }) - expect(JSON.stringify(manifest)).not.toContain('workspace:') - } finally { - await rm(destination, { recursive: true, force: true }) - } - }) - - test('verify-tarball rejects unresolved workspace dependencies', async () => { - const fixtureDir = await mkdtemp(join(tmpdir(), 'devup-ui-invalid-pack-')) - try { - await Bun.write( - join(fixtureDir, 'package.json'), - JSON.stringify({ - name: 'invalid-workspace-package', - version: '1.0.0', - dependencies: { '@devup-ui/react': 'workspace:^' }, - }), - ) - const packed = await run( - ['npm', 'pack', '--json', '--pack-destination', fixtureDir], - fixtureDir, - ) - expect(packed.exitCode).toBe(0) - const [{ filename }] = JSON.parse(packed.stdout) as Array<{ - filename: string - }> - - const verified = await run( - ['bun', publishScript, '--verify-tarball', join(fixtureDir, filename)], - rootDir, - ) - expect(verified.exitCode).toBe(1) - expect(verified.stderr).toContain( - 'Unresolved workspace dependency: dependencies.@devup-ui/react', - ) - } finally { - await rm(fixtureDir, { recursive: true, force: true }) - } - }) -}) diff --git a/publish-package.ts b/publish-package.ts deleted file mode 100644 index 0e1dbb3b4..000000000 --- a/publish-package.ts +++ /dev/null @@ -1,181 +0,0 @@ -import { mkdir, mkdtemp, readdir, readFile, rm } from 'node:fs/promises' -import { tmpdir } from 'node:os' -import { isAbsolute, join, relative, resolve } from 'node:path' - -const DEPENDENCY_FIELDS = [ - 'dependencies', - 'devDependencies', - 'optionalDependencies', - 'peerDependencies', -] as const - -interface PackageManifest { - name?: string - version?: string - private?: boolean - dependencies?: Record - devDependencies?: Record - optionalDependencies?: Record - peerDependencies?: Record -} - -interface CommandResult { - exitCode: number - stdout: string - stderr: string -} - -async function runCaptured( - command: string[], - cwd: string, -): Promise { - const process = Bun.spawn(command, { - cwd, - env: globalThis.process.env, - stdout: 'pipe', - stderr: 'pipe', - }) - const [exitCode, stdout, stderr] = await Promise.all([ - process.exited, - new Response(process.stdout).text(), - new Response(process.stderr).text(), - ]) - return { exitCode, stdout, stderr } -} - -async function runOrThrow(command: string[], cwd: string): Promise { - const result = await runCaptured(command, cwd) - if (result.exitCode !== 0) { - throw new Error( - `${command.join(' ')} failed with exit code ${result.exitCode}\n${result.stderr}`, - ) - } - return result.stdout.trim() -} - -async function readPackedManifest(tarball: string): Promise { - const output = await runOrThrow( - ['tar', '-xOf', tarball, 'package/package.json'], - globalThis.process.cwd(), - ) - return JSON.parse(output) as PackageManifest -} - -async function verifyTarball(tarball: string): Promise { - const manifest = await readPackedManifest(tarball) - for (const field of DEPENDENCY_FIELDS) { - for (const [name, range] of Object.entries(manifest[field] ?? {})) { - if (range.startsWith('workspace:')) { - throw new Error(`Unresolved workspace dependency: ${field}.${name}`) - } - } - } -} - -async function packPackage( - packageDir: string, - destination: string, -): Promise { - await mkdir(destination, { recursive: true }) - const output = await runOrThrow( - ['bun', 'pm', 'pack', '--quiet', '--destination', destination], - packageDir, - ) - const packedPath = output - .split(/\r?\n/) - .findLast((line) => line.trim().endsWith('.tgz')) - ?.trim() - if (!packedPath) throw new Error('bun pm pack did not return a tarball path') - - const tarball = isAbsolute(packedPath) - ? packedPath - : resolve(packageDir, packedPath) - await verifyTarball(tarball) - return tarball -} - -async function getPublicPackageDirs(rootDir: string): Promise { - const packageDirs: string[] = [] - for (const parent of ['packages', 'bindings']) { - const parentDir = join(rootDir, parent) - const entries = await readdir(parentDir, { withFileTypes: true }) - for (const entry of entries) { - if (!entry.isDirectory()) continue - const packageDir = join(parentDir, entry.name) - const manifest = JSON.parse( - await readFile(join(packageDir, 'package.json'), 'utf8'), - ) as PackageManifest - if (manifest.private !== true) packageDirs.push(packageDir) - } - } - return packageDirs.sort() -} - -async function verifyWorkspace(rootDir: string): Promise { - const destination = await mkdtemp(join(tmpdir(), 'devup-ui-verify-')) - try { - for (const packageDir of await getPublicPackageDirs(rootDir)) { - await packPackage(packageDir, destination) - console.info( - `verified ${relative(rootDir, packageDir).replaceAll('\\', '/')}`, - ) - } - } finally { - await rm(destination, { recursive: true, force: true }) - } -} - -async function publishTarball(tarball: string, dryRun: boolean): Promise { - const command = ['npm', 'publish', tarball] - if (dryRun) command.push('--dry-run') - const process = Bun.spawn(command, { - cwd: globalThis.process.cwd(), - env: globalThis.process.env, - stdin: 'inherit', - stdout: 'inherit', - stderr: 'inherit', - }) - const exitCode = await process.exited - if (exitCode !== 0) { - throw new Error(`npm publish failed with exit code ${exitCode}`) - } -} - -async function main(): Promise { - const [mode, value] = Bun.argv.slice(2) - if (mode === '--verify-workspace') { - await verifyWorkspace(import.meta.dir) - return - } - - if (mode === '--verify-tarball') { - if (!value) throw new Error('--verify-tarball requires a tarball path') - await verifyTarball(resolve(value)) - return - } - - if (mode === '--pack-only') { - if (!value) throw new Error('--pack-only requires a destination') - await packPackage(globalThis.process.cwd(), resolve(value)) - return - } - - if (mode && mode !== '--dry-run') { - throw new Error(`Unknown argument: ${mode}`) - } - - const destination = await mkdtemp(join(tmpdir(), 'devup-ui-publish-')) - try { - const tarball = await packPackage(globalThis.process.cwd(), destination) - await publishTarball(tarball, mode === '--dry-run') - } finally { - await rm(destination, { recursive: true, force: true }) - } -} - -try { - await main() -} catch (error) { - console.error(error instanceof Error ? error.message : error) - globalThis.process.exitCode = 1 -}