diff --git a/.github/actions/publish-preview/dist/index.mjs b/.github/actions/publish-preview/dist/index.mjs index 5149af5..31cbb83 100644 --- a/.github/actions/publish-preview/dist/index.mjs +++ b/.github/actions/publish-preview/dist/index.mjs @@ -1,5 +1,8 @@ // GENERATED by scripts/build-action.mjs — do not edit. Run `pnpm build:action`. +// .github/actions/publish-preview/src/index.ts +import { relative } from "node:path"; + // node_modules/.pnpm/nanotar@0.3.0/node_modules/nanotar/dist/index.mjs var tarItemTypeMap = { // Standard @@ -320,9 +323,6 @@ var PREVIEW_PACKAGES = /* @__PURE__ */ new Set([ "@voidzero-dev/vite-plus-core", "vite-plus" ]); -function isPreviewPackage(name) { - return PREVIEW_PACKAGES.has(name); -} function isWorkspacePackage(name, env) { const patterns = (env.WORKSPACE_PACKAGES ?? "").split(",").map((s) => s.trim()).filter(Boolean); if (patterns.length === 0) return PREVIEW_PACKAGES.has(name); @@ -343,14 +343,17 @@ function commitVersion(sha) { function shaToVersion(ref) { return /^[0-9a-f]{7,40}$/i.test(ref) ? commitVersion(ref) : null; } -function parsePreviewVersion(version) { - const commit = version.match(/^0\.0\.0-commit\.([0-9a-f]{7,40})$/i); - if (commit) return { type: "commit", ref: commit[1] }; - return null; -} // src/tarball/rewritePackageJson.ts +var DEPENDENCY_FIELDS = [ + "dependencies", + "peerDependencies", + "optionalDependencies" +]; function pkgPrNewUrlToVersion(spec, env) { + if (!env.PKG_PR_NEW_BASE || !env.PREVIEW_OWNER || !env.PREVIEW_REPO) { + return null; + } const prefix = `${env.PKG_PR_NEW_BASE}/${env.PREVIEW_OWNER}/${env.PREVIEW_REPO}/`; if (!spec.startsWith(prefix)) return null; const rest = spec.slice(prefix.length); @@ -360,11 +363,11 @@ function pkgPrNewUrlToVersion(spec, env) { if (!isWorkspacePackage(name, env)) return null; return shaToVersion(rest.slice(at + 1)); } -function rewriteDependencies(deps, version, env) { +function rewriteDependencies(deps, version, env, pinned) { if (!deps) return deps; const next = { ...deps }; for (const [name, spec] of Object.entries(deps)) { - if (PREVIEW_PACKAGES.has(name)) { + if (pinned.has(name)) { next[name] = version; continue; } @@ -373,22 +376,15 @@ function rewriteDependencies(deps, version, env) { } return next; } -function rewritePackageJson(pkg, packageName, version, env) { +function rewritePackageJson(pkg, packageName, version, env, batch) { + const pinned = batch ?? PREVIEW_PACKAGES; const next = { ...pkg }; next.name = packageName; next.version = version; - if (next.dependencies) { - next.dependencies = rewriteDependencies(next.dependencies, version, env); - } - if (next.peerDependencies) { - next.peerDependencies = rewriteDependencies(next.peerDependencies, version, env); - } - if (next.optionalDependencies) { - next.optionalDependencies = rewriteDependencies( - next.optionalDependencies, - version, - env - ); + for (const field of DEPENDENCY_FIELDS) { + if (next[field]) { + next[field] = rewriteDependencies(next[field], version, env, pinned); + } } return next; } @@ -471,9 +467,9 @@ async function parsePackageJson(gzippedTarball) { throw new HttpError(422, "Invalid package/package.json in upstream tarball"); } } -async function buildPreviewTarball(gzippedTarball, packageName, version, env) { +async function buildPreviewTarball(gzippedTarball, packageName, version, env, batch) { const { files, pkgEntry, pkg } = await parsePackageJson(gzippedTarball); - const rewritten = rewritePackageJson(pkg, packageName, version, env); + const rewritten = rewritePackageJson(pkg, packageName, version, env, batch); const rewrittenBytes = encodePackageJson(rewritten); const out = []; for (const file of files) { @@ -490,13 +486,6 @@ async function buildPreviewTarball(gzippedTarball, packageName, version, env) { return { tarball, packageJson: rewritten, shasum, integrity }; } -// src/preview/toPkgPrNewUrl.ts -function toPkgPrNewUrl(env, packageName, version) { - const preview = parsePreviewVersion(version); - if (!preview) return null; - return `${env.PKG_PR_NEW_BASE}/${env.PREVIEW_OWNER}/${env.PREVIEW_REPO}/${packageName}@${preview.ref}`; -} - // src/preview/parseConfiguredPreviewRefs.ts function parseSingleRef(value) { const commit = value.match(/^commit\.([0-9a-f]{7,40})$/i); @@ -523,7 +512,103 @@ function parseConfiguredPreviewRefs(input2) { }); } +// .github/actions/publish-preview/src/localPack.ts +import { execFile } from "node:child_process"; +import { + mkdtempSync, + readdirSync, + readFileSync, + rmSync, + statSync +} from "node:fs"; +import { tmpdir } from "node:os"; +import { join, resolve } from "node:path"; +import { promisify } from "node:util"; +var execFileAsync = promisify(execFile); +function parsePackagesInput(raw) { + return raw.split(/[\n,]/).map((s) => s.trim()).filter(Boolean); +} +function hasPackageJson(dir) { + try { + return statSync(join(dir, "package.json")).isFile(); + } catch { + return false; + } +} +function expandPackageDirs(patterns, cwd) { + const dirs = []; + for (const pattern of patterns) { + if (pattern.endsWith("/*")) { + const parent = resolve(cwd, pattern.slice(0, -2)); + let entries; + try { + entries = readdirSync(parent); + } catch { + throw new Error(`packages pattern matched no directory: ${pattern}`); + } + for (const entry of entries.sort()) { + const dir = join(parent, entry); + if (hasPackageJson(dir)) dirs.push(dir); + } + } else { + const dir = resolve(cwd, pattern); + if (!hasPackageJson(dir)) { + throw new Error(`not a package directory (no package.json): ${pattern}`); + } + dirs.push(dir); + } + } + return [...new Set(dirs)]; +} +function readManifest(dir) { + return JSON.parse(readFileSync(join(dir, "package.json"), "utf8")); +} +function assertValidBatch(packages, env) { + if (packages.length === 0) { + throw new Error("packages matched no package directories"); + } + const batch = /* @__PURE__ */ new Set(); + for (const { dir, manifest } of packages) { + const name = manifest.name; + if (!name || !isWorkspacePackage(name, env)) { + throw new Error(`not an allowed workspace package: ${name} (${dir})`); + } + if (batch.has(name)) throw new Error(`duplicate package in batch: ${name}`); + batch.add(name); + } + for (const { manifest } of packages) { + for (const field of DEPENDENCY_FIELDS) { + for (const dep of Object.keys(manifest[field] ?? {})) { + if (isWorkspacePackage(dep, env) && !batch.has(dep)) { + throw new Error( + `${manifest.name} ${field} needs ${dep}, which is not in this publish batch` + ); + } + } + } + } + return batch; +} +async function packDirectory(dir) { + const dest = mkdtempSync(join(tmpdir(), "bridge-pack-")); + try { + await execFileAsync("pnpm", ["pack", "--pack-destination", dest], { + cwd: dir + }); + const tgzs = readdirSync(dest).filter((f) => f.endsWith(".tgz")); + if (tgzs.length !== 1) { + throw new Error( + `expected pnpm pack to produce one tarball for ${dir}, found ${tgzs.length}` + ); + } + return new Uint8Array(readFileSync(join(dest, tgzs[0]))); + } finally { + rmSync(dest, { recursive: true, force: true }); + } +} + // .github/actions/publish-preview/src/index.ts +var DEFAULT_PACKAGES = "packages/cli,packages/core,packages/prompts,packages/cli/npm/*,packages/cli/cli-npm/*"; function input(name, required = false) { const value = (process.env[`INPUT_${name.toUpperCase()}`] ?? "").trim(); if (required && !value) throw new Error(`missing required input: ${name}`); @@ -543,15 +628,6 @@ async function withRetry(label, fn, attempts = 4) { } var TRANSFER_TIMEOUT_MS = 12e4; var PUBLISH_TIMEOUT_MS = 3e4; -async function fetchUpstream(env, name, version) { - const url = toPkgPrNewUrl(env, name, version); - if (!url) throw new Error(`cannot build pkg.pr.new url for ${name}@${version}`); - return withRetry(`download ${name}`, async () => { - const res = await fetch(url, { signal: AbortSignal.timeout(TRANSFER_TIMEOUT_MS) }); - if (!res.ok) throw new Error(`HTTP ${res.status} for ${url}`); - return new Uint8Array(await res.arrayBuffer()); - }); -} async function uploadTarball(bridge, token, name, version, bytes) { await withRetry(`upload ${name}`, async () => { const res = await fetch(`${bridge}/-/tarball/${name}/${version}.tgz`, { @@ -571,15 +647,18 @@ async function main() { const bridge = (input("bridge-url") || "https://registry-bridge.viteplus.dev").replace(/\/+$/, ""); const token = input("admin-token", true); const prUrl = input("pr-url") || void 0; + const cwd = process.cwd(); + const dirs = expandPackageDirs( + parsePackagesInput(input("packages") || DEFAULT_PACKAGES), + cwd + ); + const packages = dirs.map((dir) => ({ dir, manifest: readManifest(dir) })); const env = { PUBLIC_BASE_URL: bridge, - PKG_PR_NEW_BASE: (input("pkg-pr-new-base") || "https://pkg.pr.new").replace(/\/+$/, ""), - PREVIEW_OWNER: input("owner") || "voidzero-dev", - PREVIEW_REPO: input("repo") || "vite-plus", WORKSPACE_PACKAGES: input("workspace-packages") || "vite-plus,@voidzero-dev/vite-plus-*" }; - console.log(`publishing ${version} to ${bridge}`); - let published = 0; + const batch = assertValidBatch(packages, env); + console.log(`publishing ${version} (${packages.length} packages) to ${bridge}`); const post = (path, body, label) => withRetry(label, async () => { const res = await fetch(`${bridge}${path}`, { method: "POST", @@ -589,36 +668,33 @@ async function main() { }); if (!res.ok) throw new Error(`HTTP ${res.status}: ${await res.text().catch(() => "")}`); }); - const publishPackage = async (name, ver) => { - const upstream = await fetchUpstream(env, name, ver); - const build = await buildPreviewTarball(upstream, name, ver, env); - await uploadTarball(bridge, token, name, ver, build.tarball); + const startPack = (i) => { + const packed = packDirectory(packages[i].dir); + packed.catch(() => { + }); + return packed; + }; + let nextPack = startPack(0); + for (let i = 0; i < packages.length; i++) { + const { dir, manifest } = packages[i]; + const packed = await nextPack; + if (i + 1 < packages.length) nextPack = startPack(i + 1); + const build = await buildPreviewTarball(packed, manifest.name, version, env, batch); + await uploadTarball(bridge, token, manifest.name, version, build.tarball); const pkg = { - name, - version: ver, + name: manifest.name, + version, packageJson: build.packageJson, integrity: build.integrity, shasum: build.shasum }; - await post("/-/publish", { ref, packages: [pkg] }, `publish ${name}`); - published++; - console.log(` \u2713 ${name}@${ver} (${build.tarball.byteLength} bytes)`); - return pkg; - }; - let vitePlusPackageJson; - for (const name of PREVIEW_PACKAGES) { - const pkg = await publishPackage(name, version); - if (name === "vite-plus") vitePlusPackageJson = pkg.packageJson; - } - const optionalDeps = vitePlusPackageJson?.optionalDependencies ?? {}; - const binaries = Object.entries(optionalDeps).filter( - ([name]) => isWorkspacePackage(name, env) && !isPreviewPackage(name) - ); - for (const [name, depVersion] of binaries) { - await publishPackage(name, depVersion); + await post("/-/publish", { ref, packages: [pkg] }, `publish ${manifest.name}`); + console.log( + ` \u2713 ${manifest.name}@${version} (${build.tarball.byteLength} bytes, from ${relative(cwd, dir)})` + ); } await post("/-/register", { ref, prUrl }, "register ref"); - console.log(`published ${published} packages, registered ${ref}`); + console.log(`published ${packages.length} packages, registered ${ref}`); const out = process.env.GITHUB_OUTPUT; if (out) { const { appendFileSync } = await import("node:fs"); diff --git a/.github/actions/publish-preview/src/index.ts b/.github/actions/publish-preview/src/index.ts index f50d74b..11a5b0a 100644 --- a/.github/actions/publish-preview/src/index.ts +++ b/.github/actions/publish-preview/src/index.ts @@ -1,37 +1,41 @@ /** - * GitHub Action: build + hash a commit's pkg.pr.new preview artifacts and - * upload them to the registry bridge. + * GitHub Action: pack a commit's locally built packages and publish them to + * the registry bridge. * - * This runs the CPU/memory-heavy work (decompress, rewrite, re-pack, hash) in - * CI, where there is no per-invocation limit, so the Cloudflare Worker only ever - * streams bytes. It reuses the Worker's own rewrite/digest modules, so the - * artifacts CI produces are exactly what the Worker would describe. + * Runs in the same CI job that assembled the build artifacts (the exact + * directories pkg.pr.new publishes from), so the bridge does not depend on a + * pkg.pr.new upload -> download round-trip. Each directory is packed with + * `pnpm pack` (resolving workspace:/catalog: specs; run `pnpm install` + * first), then rewritten, re-packed and hashed with the Worker's own modules, + * so CI's artifacts are exactly what the Worker would describe. This also + * runs the CPU/memory-heavy work (rewrite, re-pack, hash) in CI, where there + * is no per-invocation limit, so the Cloudflare Worker only ever streams + * bytes. * - * Every package, the small preview packages (vite-plus, core) AND the platform - * binaries, is rewritten (name/version, plus deps for the preview packages) and - * re-packed, with integrity over the re-packed bytes. The binary's version is - * rewritten too so the tarball's internal version matches the resolved version: - * pnpm's `strict-store-pkg-content-check` rejects a tarball whose package.json - * version differs from what it resolved, so an as-is upload (version 0.2.1) would - * fail there even though npm/yarn/bun tolerate it. + * Every package is rewritten to the synthetic commit version, and deps + * between packages of the same batch are pinned to it (the coherence + * pkg.pr.new's URL rewriting used to provide). The version rewrite matters + * even for the platform binaries: pnpm's `strict-store-pkg-content-check` + * rejects a tarball whose package.json version differs from the version it + * resolved. */ +import { relative } from 'node:path' import { buildPreviewTarball } from '../../../../src/tarball/buildPreviewTarball' -import { toPkgPrNewUrl } from '../../../../src/preview/toPkgPrNewUrl' -import { - isPreviewPackage, - isWorkspacePackage, - PREVIEW_PACKAGES, -} from '../../../../src/preview/packages' import { parseConfiguredPreviewRefs } from '../../../../src/preview/parseConfiguredPreviewRefs' import type { RewriteEnv } from '../../../../src/tarball/rewritePackageJson' +import { + assertValidBatch, + expandPackageDirs, + packDirectory, + parsePackagesInput, + readManifest, +} from './localPack' -interface PublishPackage { - name: string - version: string - packageJson: Record - integrity: string - shasum: string -} +// The vite-plus layout, overridable via the `packages` input. Lives here (not +// action.yml) so direct invocations of the bundle (scripts/warm.mjs) get the +// same default. +const DEFAULT_PACKAGES = + 'packages/cli,packages/core,packages/prompts,packages/cli/npm/*,packages/cli/cli-npm/*' function input(name: string, required = false): string { const value = (process.env[`INPUT_${name.toUpperCase()}`] ?? '').trim() @@ -56,25 +60,11 @@ async function withRetry(label: string, fn: () => Promise, attempts = 4): // undici's ~5-minute defaults, and 4 retry attempts across ~11 packages can // zombie a publish for tens of minutes (the 2026-07-02 incident's cancelled // attempt ran 21 minutes mid-publish). Bounding each attempt keeps retries -// snappy and the whole run inside a predictable window. Transfers move up to -// ~19 MB per request; the register call is a small JSON POST. +// snappy and the whole run inside a predictable window. Uploads move up to +// ~19 MB per request; the publish/register calls are small JSON POSTs. const TRANSFER_TIMEOUT_MS = 120_000 const PUBLISH_TIMEOUT_MS = 30_000 -async function fetchUpstream( - env: RewriteEnv, - name: string, - version: string, -): Promise { - const url = toPkgPrNewUrl(env, name, version) - if (!url) throw new Error(`cannot build pkg.pr.new url for ${name}@${version}`) - return withRetry(`download ${name}`, async () => { - const res = await fetch(url, { signal: AbortSignal.timeout(TRANSFER_TIMEOUT_MS) }) - if (!res.ok) throw new Error(`HTTP ${res.status} for ${url}`) - return new Uint8Array(await res.arrayBuffer()) - }) -} - async function uploadTarball( bridge: string, token: string, @@ -104,16 +94,26 @@ async function main(): Promise { const token = input('admin-token', true) // Optional: present on pull_request runs, empty on push/commit runs. const prUrl = input('pr-url') || undefined + + const cwd = process.cwd() + const dirs = expandPackageDirs( + parsePackagesInput(input('packages') || DEFAULT_PACKAGES), + cwd, + ) + const packages = dirs.map((dir) => ({ dir, manifest: readManifest(dir) })) + + // Locally packed manifests never carry pkg.pr.new URL deps, so the + // pkg.pr.new fields of the shared config stay unset here. const env: RewriteEnv = { PUBLIC_BASE_URL: bridge, - PKG_PR_NEW_BASE: (input('pkg-pr-new-base') || 'https://pkg.pr.new').replace(/\/+$/, ''), - PREVIEW_OWNER: input('owner') || 'voidzero-dev', - PREVIEW_REPO: input('repo') || 'vite-plus', WORKSPACE_PACKAGES: input('workspace-packages') || 'vite-plus,@voidzero-dev/vite-plus-*', } - console.log(`publishing ${version} to ${bridge}`) - let published = 0 + // The batch (every name publishing together) drives dependency rewriting; + // validation fails up front, before anything is packed or uploaded. + const batch = assertValidBatch(packages, env) + + console.log(`publishing ${version} (${packages.length} packages) to ${bridge}`) const post = (path: string, body: Record, label: string) => withRetry(label, async () => { @@ -126,51 +126,48 @@ async function main(): Promise { if (!res.ok) throw new Error(`HTTP ${res.status}: ${await res.text().catch(() => '')}`) }) - // Download from pkg.pr.new, rewrite + re-pack + hash, upload the bytes, and - // immediately publish this package's meta, so the stored tarball and the - // meta the packument serves can never diverge for longer than this one - // package's upload. A run cancelled part-way leaves later packages untouched - // instead of stranding bytes without metas (the mixed state a cancelled run - // previously served for its whole upload window). Reuses the Worker's own - // build so CI's artifact is exactly what the Worker would describe. - const publishPackage = async (name: string, ver: string): Promise => { - const upstream = await fetchUpstream(env, name, ver) - const build = await buildPreviewTarball(upstream, name, ver, env) - await uploadTarball(bridge, token, name, ver, build.tarball) - const pkg: PublishPackage = { - name, - version: ver, + // Pack, rewrite + re-pack + hash, upload the bytes, and immediately publish + // this package's meta, so the stored tarball and the meta the packument + // serves can never diverge for longer than this one package's upload. A run + // cancelled part-way leaves later packages untouched instead of stranding + // bytes without metas. Reuses the Worker's own build so CI's artifact is + // exactly what the Worker would describe. + // + // The next `pnpm pack` runs while the current package uploads (one ahead, + // bounding memory to two tarballs); the bridge-visible order, upload then + // publish per package with register last, is unchanged. The swallowed + // rejection resurfaces at the `await`; it only avoids an unhandled-rejection + // crash when an upload fails first. + const startPack = (i: number) => { + const packed = packDirectory(packages[i].dir) + packed.catch(() => {}) + return packed + } + let nextPack = startPack(0) + for (let i = 0; i < packages.length; i++) { + const { dir, manifest } = packages[i] + const packed = await nextPack + if (i + 1 < packages.length) nextPack = startPack(i + 1) + const build = await buildPreviewTarball(packed, manifest.name, version, env, batch) + await uploadTarball(bridge, token, manifest.name, version, build.tarball) + const pkg = { + name: manifest.name, + version, packageJson: build.packageJson, integrity: build.integrity, shasum: build.shasum, } - await post('/-/publish', { ref, packages: [pkg] }, `publish ${name}`) - published++ - console.log(` ✓ ${name}@${ver} (${build.tarball.byteLength} bytes)`) - return pkg - } - - // Preview packages first; vite-plus's rewritten optionalDependencies name the - // platform binaries (each at the version vite-plus declares for it). - let vitePlusPackageJson: Record | undefined - for (const name of PREVIEW_PACKAGES) { - const pkg = await publishPackage(name, version) - if (name === 'vite-plus') vitePlusPackageJson = pkg.packageJson - } - - const optionalDeps = (vitePlusPackageJson?.optionalDependencies ?? {}) as Record - const binaries = Object.entries(optionalDeps).filter( - ([name]) => isWorkspacePackage(name, env) && !isPreviewPackage(name), - ) - for (const [name, depVersion] of binaries) { - await publishPackage(name, depVersion) + await post('/-/publish', { ref, packages: [pkg] }, `publish ${manifest.name}`) + console.log( + ` ✓ ${manifest.name}@${version} (${build.tarball.byteLength} bytes, from ${relative(cwd, dir)})`, + ) } // Every package's bytes + meta are stored; registering the ref last flips // the whole version visible atomically (a brand-new ref is not served at // all until this succeeds). await post('/-/register', { ref, prUrl }, 'register ref') - console.log(`published ${published} packages, registered ${ref}`) + console.log(`published ${packages.length} packages, registered ${ref}`) const out = process.env.GITHUB_OUTPUT if (out) { diff --git a/.github/actions/publish-preview/src/localPack.ts b/.github/actions/publish-preview/src/localPack.ts new file mode 100644 index 0000000..87fa693 --- /dev/null +++ b/.github/actions/publish-preview/src/localPack.ts @@ -0,0 +1,140 @@ +/** + * Local packing for the publish action: expand the `packages` input into + * concrete directories and pack each with `pnpm pack`. pnpm (not npm) because + * it resolves `workspace:`/`catalog:` specs to concrete versions the way a + * real publish would, which requires the workspace to be installed + * (`pnpm install`) before the action runs. + */ +import { execFile } from 'node:child_process' +import { + mkdtempSync, + readdirSync, + readFileSync, + rmSync, + statSync, +} from 'node:fs' +import { tmpdir } from 'node:os' +import { join, resolve } from 'node:path' +import { promisify } from 'node:util' +import { isWorkspacePackage } from '../../../../src/preview/packages' +import { DEPENDENCY_FIELDS } from '../../../../src/tarball/rewritePackageJson' + +const execFileAsync = promisify(execFile) + +/** Split the `packages` input (newline- or comma-separated) into patterns. */ +export function parsePackagesInput(raw: string): string[] { + return raw + .split(/[\n,]/) + .map((s) => s.trim()) + .filter(Boolean) +} + +function hasPackageJson(dir: string): boolean { + try { + return statSync(join(dir, 'package.json')).isFile() + } catch { + return false + } +} + +/** + * Expand package patterns into directories containing a package.json. A + * pattern ending in `/*` matches every direct subdirectory that has one + * (subdirectories without one are skipped: which platform dirs exist varies + * per build); any other pattern must itself be such a directory, so a typo + * fails loudly instead of silently publishing fewer packages. + */ +export function expandPackageDirs(patterns: string[], cwd: string): string[] { + const dirs: string[] = [] + for (const pattern of patterns) { + if (pattern.endsWith('/*')) { + const parent = resolve(cwd, pattern.slice(0, -2)) + let entries: string[] + try { + entries = readdirSync(parent) + } catch { + throw new Error(`packages pattern matched no directory: ${pattern}`) + } + for (const entry of entries.sort()) { + const dir = join(parent, entry) + if (hasPackageJson(dir)) dirs.push(dir) + } + } else { + const dir = resolve(cwd, pattern) + if (!hasPackageJson(dir)) { + throw new Error(`not a package directory (no package.json): ${pattern}`) + } + dirs.push(dir) + } + } + return [...new Set(dirs)] +} + +/** Read a directory's package.json (the manifest as authored on disk). */ +export function readManifest(dir: string): Record { + return JSON.parse(readFileSync(join(dir, 'package.json'), 'utf8')) +} + +/** A package directory queued for publishing, with its on-disk manifest. */ +export interface PackageDir { + dir: string + manifest: Record +} + +/** + * Validate the publish batch and return its package names (the set that + * dependency rewriting pins to the synthetic version). Every manifest must be + * an allowed workspace package, named once; and every workspace dep must be + * satisfied by the batch, since a missing member (e.g. a platform dir a + * failed build never produced) would leave a dep pinned to a version that + * will never exist on the bridge, breaking installs only on that platform. + * Runs before anything is packed or uploaded. + */ +export function assertValidBatch( + packages: PackageDir[], + env: { WORKSPACE_PACKAGES?: string }, +): Set { + if (packages.length === 0) { + throw new Error('packages matched no package directories') + } + const batch = new Set() + for (const { dir, manifest } of packages) { + const name = manifest.name as string | undefined + if (!name || !isWorkspacePackage(name, env)) { + throw new Error(`not an allowed workspace package: ${name} (${dir})`) + } + if (batch.has(name)) throw new Error(`duplicate package in batch: ${name}`) + batch.add(name) + } + for (const { manifest } of packages) { + for (const field of DEPENDENCY_FIELDS) { + for (const dep of Object.keys(manifest[field] ?? {})) { + if (isWorkspacePackage(dep, env) && !batch.has(dep)) { + throw new Error( + `${manifest.name} ${field} needs ${dep}, which is not in this publish batch`, + ) + } + } + } + } + return batch +} + +/** Pack one directory with `pnpm pack` and return the gzipped tarball bytes. */ +export async function packDirectory(dir: string): Promise { + const dest = mkdtempSync(join(tmpdir(), 'bridge-pack-')) + try { + await execFileAsync('pnpm', ['pack', '--pack-destination', dest], { + cwd: dir, + }) + const tgzs = readdirSync(dest).filter((f) => f.endsWith('.tgz')) + if (tgzs.length !== 1) { + throw new Error( + `expected pnpm pack to produce one tarball for ${dir}, found ${tgzs.length}`, + ) + } + return new Uint8Array(readFileSync(join(dest, tgzs[0]))) + } finally { + rmSync(dest, { recursive: true, force: true }) + } +} diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 502f8c4..3e8af87 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -44,7 +44,7 @@ jobs: git diff --exit-code -- .github/actions/publish-preview/dist # vitest runs the Worker in workerd (Miniflare); no network or secrets. - # The deploy-time checks (warm + bun e2e) need a live deployment and run - # from `pnpm run deploy`, not here. + # The deploy-time check (bun e2e) needs a live deployment and runs from + # `pnpm run deploy`, not here. - name: Test run: pnpm test diff --git a/README.md b/README.md index 17ddb96..9c4a8dc 100644 --- a/README.md +++ b/README.md @@ -49,23 +49,28 @@ for a runnable example. (`GET //-/-.tgz`) for clients and lockfiles that synthesize that URL instead of reading `dist.tarball`; non-preview packages/versions there are redirected to npm. -- **Transitive deps**: a preview build's `optionalDependencies` point at - pkg.pr.new (the platform binaries). The bridge rewrites those URLs to - synthetic **version strings** (`0.0.0-commit.`) and serves packuments for - those packages too, so they resolve through the bridge like the other preview - packages, and the package manager downloads only the binary for the current - platform (reading os/cpu from the packument) instead of all of them. The - binaries are large (tens of MB), so they are repacked + hashed in CI (where - there is no per-request limit) and uploaded; the binary's `package.json` - version is rewritten to the synthetic version so it matches what the resolver - expects (pnpm's strict store check rejects a mismatch). A platform binary not - yet uploaded for a registered ref redirects to pkg.pr.new as a best-effort - fallback. The small preview packages can also be built in-Worker on demand as a - fallback (they are small enough to stay within the limits). +- **Transitive deps**: a preview build's `optionalDependencies` (the platform + binaries) are pinned to the same synthetic **version string** + (`0.0.0-commit.`), and the bridge serves packuments for those packages + too, so they resolve through the bridge like the other preview packages, and + the package manager downloads only the binary for the current platform + (reading os/cpu from the packument) instead of all of them. The publish + action pins any dep on a package of the same publish batch; the Worker's + on-demand fallback equivalently rewrites the pkg.pr.new dependency URLs + found in upstream tarballs. The binaries are large (tens of MB), so they are + packed + hashed in CI (where there is no per-request limit) and uploaded; + the binary's `package.json` version is rewritten to the synthetic version so + it matches what the resolver expects (pnpm's strict store check rejects a + mismatch). A platform binary not yet uploaded for a registered ref redirects + to pkg.pr.new as a best-effort fallback. The small preview packages can also + be built in-Worker on demand as a fallback (they are small enough to stay + within the limits). - **Publishing** (`POST /-/publish`, `PUT /-/tarball/...`): the - [publish action](#publishing-from-ci) downloads each package from pkg.pr.new, - rewrites + re-packs + hashes it, `PUT`s the bytes, and `POST`s the metadata - (rewritten package.json + integrity) and registers the ref, all in one CI run. + [publish action](#publishing-from-ci) packs each locally built package + directory (in the same CI job that produced the artifacts, no pkg.pr.new + round-trip), rewrites + re-packs + hashes it, `PUT`s the bytes, and `POST`s + the metadata (rewritten package.json + integrity) and registers the ref, all + in one CI run. Because integrity is computed over the exact bytes served, every package manager that verifies it (npm, pnpm, yarn) gets a match, and bun/yarn-berry pin it on first install. @@ -189,15 +194,15 @@ then `POST /-/publish` (stores metadata + registers the ref), both admin-guarded and driven by the [publish action](#publishing-from-ci), not by hand. A published ref is reflected immediately and built into the packument on the next -request. This is the no-redeploy path for exposing new pkg.pr.new builds. +request. This is the no-redeploy path for exposing new preview builds. ### Publishing from CI -The heavy work (download, rewrite, re-pack, hash) runs in CI via a reusable -action, so the Worker only serves. Wire it into vite-plus's pkg.pr.new workflow: -see [`docs/ci-setup.md`](./docs/ci-setup.md). To publish by hand (same code -path), run `PKG_PR_BRIDGE_ADMIN_TOKEN=… pnpm warm `; with no arguments it -publishes the refs in `.env` (also part of `pnpm run deploy`). +The heavy work (pack, rewrite, re-pack, hash) runs in CI via a reusable +action, in the same job that built the artifacts, so the Worker only serves +and pkg.pr.new is not involved. Wire it into vite-plus's publish workflow: see +[`docs/ci-setup.md`](./docs/ci-setup.md). To publish by hand (same code path), +run `PKG_PR_BRIDGE_ADMIN_TOKEN=… pnpm warm --repo `. The action's bundle is committed (`.github/actions/publish-preview/dist/index.mjs`); rebuild it with @@ -250,16 +255,15 @@ needed). void auth login void secret put ADMIN_TOKEN # guards the admin write endpoints -# Deploy, warm the caches, and run the end-to-end bun install check. +# Deploy and run the end-to-end bun install check. # Use `pnpm run deploy` (not `pnpm deploy`, which is pnpm's built-in command). -pnpm run deploy # void deploy + warm + e2e +pnpm run deploy # void deploy + e2e ``` -`pnpm run deploy` runs `void deploy`, then `pnpm warm` (publishes the configured -preview refs into R2 via the action so installs are served from cache), then -`pnpm test:e2e` (a real `bun install` against the live bridge that asserts the -alias/override resolves to the synthetic version). Use `pnpm run deploy:only` -for `void deploy` alone. +`pnpm run deploy` runs `void deploy`, then `pnpm test:e2e` (a real +`bun install` against the live bridge that asserts the alias/override resolves +to the synthetic version, using a ref the bridge already serves). Use +`pnpm run deploy:only` for `void deploy` alone. The public origin (`PUBLIC_BASE_URL` in `.env.production`) is the custom domain `https://registry-bridge.viteplus.dev`, attached with `void domain add` (the diff --git a/action.yml b/action.yml index 03d5122..6c4634f 100644 --- a/action.yml +++ b/action.yml @@ -1,13 +1,24 @@ -name: 'Publish pkg.pr.new preview to the registry bridge' +name: 'Publish preview packages to the registry bridge' description: >- - Build and hash a commit's pkg.pr.new preview artifacts (the small preview - packages are rewritten and re-packed; the platform binaries are taken as-is) - and upload them, plus their integrity, to the pkg-pr-registry-bridge. Runs the - CPU-heavy work in CI so the Worker only serves. + Pack locally built package directories and publish them, plus their + integrity, to the pkg-pr-registry-bridge as the synthetic commit version. + Packs with `pnpm pack` (resolving workspace:/catalog: specs), so run + pnpm/action-setup and `pnpm install` first. Runs the CPU-heavy work in CI so + the Worker only serves. No pkg.pr.new involvement. inputs: sha: description: 'The commit sha to publish (a 7-40 hex sha, or commit.).' required: true + packages: + description: >- + Package directories to publish, newline- or comma-separated, relative to + the workspace. A trailing "/*" expands to every direct subdirectory + containing a package.json (e.g. packages/cli/npm/*). Pass the same + directories a registry publish would cover; deps between them are pinned + to the synthetic version. Defaults to the vite-plus layout (packages/cli, + packages/core, packages/prompts, packages/cli/npm/*, + packages/cli/cli-npm/*); the authoritative default lives in the action. + required: false bridge-url: description: 'Base URL of the registry bridge.' required: false @@ -21,18 +32,6 @@ inputs: github.event.pull_request.html_url on pull_request runs; leave empty on push/commit runs. required: false - pkg-pr-new-base: - description: 'pkg.pr.new base URL.' - required: false - default: 'https://pkg.pr.new' - owner: - description: 'Upstream repo owner.' - required: false - default: 'voidzero-dev' - repo: - description: 'Upstream repo name.' - required: false - default: 'vite-plus' workspace-packages: description: 'Allowlist (comma-separated exact names or prefix* patterns).' required: false diff --git a/docs/ci-setup.md b/docs/ci-setup.md index 4dc4d4c..cf83a41 100644 --- a/docs/ci-setup.md +++ b/docs/ci-setup.md @@ -9,16 +9,20 @@ Repo: `voidzero-dev/vite-plus` (owner/repo are fixed in the Worker) ## How it works -The bridge ships a reusable action that does the CPU-heavy work in CI: for each -package (the two preview packages and every platform binary in vite-plus's -`optionalDependencies`) it downloads the pkg.pr.new build, rewrites -`package/package.json` (name/version, plus deps for the preview packages), -re-packs, hashes, `PUT`s the tarball, and immediately `POST`s that package's -metadata (`/-/publish`), so stored bytes and served metadata can never diverge -for longer than one package's upload. A final `POST /-/register` registers the -ref, flipping the whole version visible atomically; a run cancelled mid-way -leaves only invisible artifacts. The Worker then only serves bytes from R2, -with `dist.integrity` matching the exact bytes served. +The bridge ships a reusable action that does the CPU-heavy work in CI: it packs +each locally built package directory with `pnpm pack` (resolving +`workspace:`/`catalog:` specs), rewrites `package/package.json` (name/version, +plus pinning deps between packages of the same batch to the synthetic commit +version), re-packs, hashes, `PUT`s the tarball, and immediately `POST`s that +package's metadata (`/-/publish`), so stored bytes and served metadata can +never diverge for longer than one package's upload. A final `POST /-/register` +registers the ref, flipping the whole version visible atomically; a run +cancelled mid-way leaves only invisible artifacts. The Worker then only serves +bytes from R2, with `dist.integrity` matching the exact bytes served. + +The action reads everything from the local checkout: pkg.pr.new is not +involved, so the step works even when pkg.pr.new is down and can run on +commits that never publish there. ## Setup @@ -27,9 +31,11 @@ with `dist.integrity` matching the exact bytes served. In `voidzero-dev/vite-plus` -> Settings -> Secrets and variables -> Actions, add `PKG_PR_BRIDGE_ADMIN_TOKEN` = the bridge's `ADMIN_TOKEN`. -### 2. Add the publish step to the pkg.pr.new workflow +### 2. Add the publish step to the workflow -Right after the pkg.pr.new build publishes: +In the job that assembles the build artifacts, after `pnpm install`, the +artifact downloads, and `publish-native-addons.ts --mode pkg-pr-new` (which +prepares `packages/cli/npm/*` and `packages/cli/cli-npm/*` on disk): ```yaml - uses: voidzero-dev/pkg-pr-registry-bridge@main @@ -39,13 +45,20 @@ Right after the pkg.pr.new build publishes: # Optional: surfaced by /-/refs. Omit/empty on push runs (no PR). pr-url: ${{ github.event.pull_request.html_url }} # bridge-url defaults to https://registry-bridge.viteplus.dev + # packages defaults to the vite-plus layout: packages/cli, packages/core, + # packages/prompts, packages/cli/npm/*, packages/cli/cli-npm/* ``` -> Use `github.event.pull_request.head.sha`, not `github.sha`. pkg.pr.new publishes -> under the PR **head** commit, whereas on `pull_request` events `github.sha` is -> the ephemeral **merge** commit; publishing that would point at a SHA pkg.pr.new -> never built. On `push` events (no merge commit) `github.sha` is the head commit -> and is correct. +The runner needs pnpm on `PATH` (pnpm/action-setup) and the workspace +installed (`pnpm install`), or `pnpm pack` cannot resolve `workspace:` specs. +Deps between the published packages are pinned to the synthetic version, so a +package whose dep is missing from the batch fails the run up front (before +anything uploads) rather than publishing a version with a dangling dep. + +> Use `github.event.pull_request.head.sha`, not `github.sha`: on `pull_request` +> events `github.sha` is the ephemeral **merge** commit, whereas the checkout +> being packed is the PR **head** commit. On `push` events (no merge commit) +> `github.sha` is the head commit and is correct. ### 3. Verify @@ -57,7 +70,8 @@ curl https://registry-bridge.viteplus.dev/-/refs ## Notes -- To publish a commit by hand (same code path as the action), run - `PKG_PR_BRIDGE_ADMIN_TOKEN=… pnpm warm `. +- To publish a commit by hand (same code path as the action), build a + vite-plus checkout at that commit the way the workflow does, then run + `PKG_PR_BRIDGE_ADMIN_TOKEN=… pnpm warm --repo `. - A long-lived PR accumulates one `commit.` ref per pushed commit. Purge stale ones with `POST /-/purge` if the packument grows too large. diff --git a/package.json b/package.json index 68b1baf..4ec78dd 100644 --- a/package.json +++ b/package.json @@ -13,7 +13,7 @@ "warm": "node scripts/warm.mjs", "smoke": "node scripts/smoke-test.mjs", "deploy:staging": "void deploy --project pkg-pr-registry-bridge-staging", - "typecheck": "tsc --noEmit", + "typecheck": "tsc --noEmit && tsc -p tsconfig.action.json", "test": "vitest run", "test:watch": "vitest", "test:e2e": "node scripts/e2e-bun.mjs", @@ -25,6 +25,7 @@ }, "devDependencies": { "@cloudflare/vitest-pool-workers": "^0.16.20", + "@types/node": "^24.13.2", "esbuild": "^0.28.1", "typescript": "^6.0.0", "vite": "^8.1.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 9d61db8..e24874d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -216,7 +216,10 @@ importers: devDependencies: '@cloudflare/vitest-pool-workers': specifier: ^0.16.20 - version: 0.16.20(@cloudflare/workers-types@4.20260626.1)(@vitest/runner@4.1.9)(@vitest/snapshot@4.1.9)(vitest@4.1.9(vite@8.1.0(esbuild@0.28.1)(tsx@4.22.4))) + version: 0.16.20(@cloudflare/workers-types@4.20260626.1)(@vitest/runner@4.1.9)(@vitest/snapshot@4.1.9)(vitest@4.1.9(@types/node@24.13.2)(vite@8.1.0(@types/node@24.13.2)(esbuild@0.28.1)(tsx@4.22.4))) + '@types/node': + specifier: ^24.13.2 + version: 24.13.2 esbuild: specifier: ^0.28.1 version: 0.28.1 @@ -225,13 +228,13 @@ importers: version: 6.0.3 vite: specifier: ^8.1.0 - version: 8.1.0(esbuild@0.28.1)(tsx@4.22.4) + version: 8.1.0(@types/node@24.13.2)(esbuild@0.28.1)(tsx@4.22.4) vitest: specifier: ^4.1.0 - version: 4.1.9(vite@8.1.0(esbuild@0.28.1)(tsx@4.22.4)) + version: 4.1.9(@types/node@24.13.2)(vite@8.1.0(@types/node@24.13.2)(esbuild@0.28.1)(tsx@4.22.4)) void: specifier: ^0.9.3 - version: 0.9.3(arktype@2.2.1)(kysely@0.29.2)(valibot@1.4.1(typescript@6.0.3))(vite@8.1.0(esbuild@0.28.1)(tsx@4.22.4))(vitest@4.1.9(vite@8.1.0(esbuild@0.28.1)(tsx@4.22.4)))(workerd@1.20260625.1)(zod@4.4.3) + version: 0.9.3(arktype@2.2.1)(kysely@0.29.2)(valibot@1.4.1(typescript@6.0.3))(vite@8.1.0(@types/node@24.13.2)(esbuild@0.28.1)(tsx@4.22.4))(vitest@4.1.9(@types/node@24.13.2)(vite@8.1.0(@types/node@24.13.2)(esbuild@0.28.1)(tsx@4.22.4)))(workerd@1.20260625.1)(zod@4.4.3) wrangler: specifier: ^4.105.0 version: 4.105.0(@cloudflare/workers-types@4.20260626.1) @@ -1268,6 +1271,9 @@ packages: '@types/estree@1.0.9': resolution: {integrity: sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==} + '@types/node@24.13.2': + resolution: {integrity: sha512-fRa09kZTgu8o71KFcDjUFuc7F+dEbZYZmkI0mg5YBTRs0yMKjYHsq/c0urDKeDb+D5qVgXOdFcuu+DZPKOITwA==} + '@vitest/expect@4.1.9': resolution: {integrity: sha512-vl/rYsUKcBr3SnQn166+XR5ZQcgMx3DQhFWdfli/cWpLnLUmbxZvyrJZotLFUryib+LtArYMSTJ5RbQ57ZqrlA==} @@ -1953,6 +1959,9 @@ packages: engines: {node: '>=14.17'} hasBin: true + undici-types@7.18.2: + resolution: {integrity: sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==} + undici@7.28.0: resolution: {integrity: sha512-cRZYrTDwWznlnRiPjggAGxZXanty6M8RV1ff8Wm4LWXBp7/IG8v5DnOm74DtUBp9OONpK75YlPnIjQqX0dBDtA==} engines: {node: '>=20.18.1'} @@ -2205,12 +2214,12 @@ snapshots: optionalDependencies: workerd: 1.20260625.1 - '@cloudflare/vite-plugin@1.42.3(vite@8.1.0(esbuild@0.28.1)(tsx@4.22.4))(workerd@1.20260625.1)(wrangler@4.105.0(@cloudflare/workers-types@4.20260626.1))': + '@cloudflare/vite-plugin@1.42.3(vite@8.1.0(@types/node@24.13.2)(esbuild@0.28.1)(tsx@4.22.4))(workerd@1.20260625.1)(wrangler@4.105.0(@cloudflare/workers-types@4.20260626.1))': dependencies: '@cloudflare/unenv-preset': 2.16.1(unenv@2.0.0-rc.24)(workerd@1.20260625.1) miniflare: 4.20260625.0 unenv: 2.0.0-rc.24 - vite: 8.1.0(esbuild@0.28.1)(tsx@4.22.4) + vite: 8.1.0(@types/node@24.13.2)(esbuild@0.28.1)(tsx@4.22.4) wrangler: 4.105.0(@cloudflare/workers-types@4.20260626.1) ws: 8.21.0 transitivePeerDependencies: @@ -2218,14 +2227,14 @@ snapshots: - utf-8-validate - workerd - '@cloudflare/vitest-pool-workers@0.16.20(@cloudflare/workers-types@4.20260626.1)(@vitest/runner@4.1.9)(@vitest/snapshot@4.1.9)(vitest@4.1.9(vite@8.1.0(esbuild@0.28.1)(tsx@4.22.4)))': + '@cloudflare/vitest-pool-workers@0.16.20(@cloudflare/workers-types@4.20260626.1)(@vitest/runner@4.1.9)(@vitest/snapshot@4.1.9)(vitest@4.1.9(@types/node@24.13.2)(vite@8.1.0(@types/node@24.13.2)(esbuild@0.28.1)(tsx@4.22.4)))': dependencies: '@vitest/runner': 4.1.9 '@vitest/snapshot': 4.1.9 cjs-module-lexer: 1.2.3 esbuild: 0.28.1 miniflare: 4.20260625.0 - vitest: 4.1.9(vite@8.1.0(esbuild@0.28.1)(tsx@4.22.4)) + vitest: 4.1.9(@types/node@24.13.2)(vite@8.1.0(@types/node@24.13.2)(esbuild@0.28.1)(tsx@4.22.4)) wrangler: 4.105.0(@cloudflare/workers-types@4.20260626.1) zod: 3.25.76 transitivePeerDependencies: @@ -2762,6 +2771,10 @@ snapshots: '@types/estree@1.0.9': {} + '@types/node@24.13.2': + dependencies: + undici-types: 7.18.2 + '@vitest/expect@4.1.9': dependencies: '@standard-schema/spec': 1.1.0 @@ -2771,13 +2784,13 @@ snapshots: chai: 6.2.2 tinyrainbow: 3.1.0 - '@vitest/mocker@4.1.9(vite@8.1.0(esbuild@0.28.1)(tsx@4.22.4))': + '@vitest/mocker@4.1.9(vite@8.1.0(@types/node@24.13.2)(esbuild@0.28.1)(tsx@4.22.4))': dependencies: '@vitest/spy': 4.1.9 estree-walker: 3.0.3 magic-string: 0.30.21 optionalDependencies: - vite: 8.1.0(esbuild@0.28.1)(tsx@4.22.4) + vite: 8.1.0(@types/node@24.13.2)(esbuild@0.28.1)(tsx@4.22.4) '@vitest/pretty-format@4.1.9': dependencies: @@ -2819,7 +2832,7 @@ snapshots: base64-js@1.5.1: {} - better-auth@1.6.22(@cloudflare/workers-types@4.20260626.1)(better-sqlite3@12.11.1)(drizzle-kit@0.31.10)(drizzle-orm@0.45.2(@cloudflare/workers-types@4.20260626.1)(better-sqlite3@12.11.1)(kysely@0.29.2)(pg@8.22.0))(pg@8.22.0)(vitest@4.1.9(vite@8.1.0(esbuild@0.28.1)(tsx@4.22.4))): + better-auth@1.6.22(@cloudflare/workers-types@4.20260626.1)(better-sqlite3@12.11.1)(drizzle-kit@0.31.10)(drizzle-orm@0.45.2(@cloudflare/workers-types@4.20260626.1)(better-sqlite3@12.11.1)(kysely@0.29.2)(pg@8.22.0))(pg@8.22.0)(vitest@4.1.9(@types/node@24.13.2)(vite@8.1.0(@types/node@24.13.2)(esbuild@0.28.1)(tsx@4.22.4))): dependencies: '@better-auth/core': 1.6.22(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@cloudflare/workers-types@4.20260626.1)(better-call@1.3.7(zod@4.4.3))(jose@6.2.3)(kysely@0.29.2)(nanostores@1.4.0) '@better-auth/drizzle-adapter': 1.6.22(@better-auth/core@1.6.22(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@cloudflare/workers-types@4.20260626.1)(better-call@1.3.7(zod@4.4.3))(jose@6.2.3)(kysely@0.29.2)(nanostores@1.4.0))(@better-auth/utils@0.4.2)(drizzle-orm@0.45.2(@cloudflare/workers-types@4.20260626.1)(better-sqlite3@12.11.1)(kysely@0.29.2)(pg@8.22.0)) @@ -2843,7 +2856,7 @@ snapshots: drizzle-kit: 0.31.10 drizzle-orm: 0.45.2(@cloudflare/workers-types@4.20260626.1)(better-sqlite3@12.11.1)(kysely@0.29.2)(pg@8.22.0) pg: 8.22.0 - vitest: 4.1.9(vite@8.1.0(esbuild@0.28.1)(tsx@4.22.4)) + vitest: 4.1.9(@types/node@24.13.2)(vite@8.1.0(@types/node@24.13.2)(esbuild@0.28.1)(tsx@4.22.4)) transitivePeerDependencies: - '@cloudflare/workers-types' - '@opentelemetry/api' @@ -3385,6 +3398,8 @@ snapshots: typescript@6.0.3: {} + undici-types@7.18.2: {} + undici@7.28.0: {} unenv@2.0.0-rc.24: @@ -3397,7 +3412,7 @@ snapshots: optionalDependencies: typescript: 6.0.3 - vite@8.1.0(esbuild@0.28.1)(tsx@4.22.4): + vite@8.1.0(@types/node@24.13.2)(esbuild@0.28.1)(tsx@4.22.4): dependencies: lightningcss: 1.32.0 picomatch: 4.0.4 @@ -3405,14 +3420,15 @@ snapshots: rolldown: 1.1.3 tinyglobby: 0.2.17 optionalDependencies: + '@types/node': 24.13.2 esbuild: 0.28.1 fsevents: 2.3.3 tsx: 4.22.4 - vitest@4.1.9(vite@8.1.0(esbuild@0.28.1)(tsx@4.22.4)): + vitest@4.1.9(@types/node@24.13.2)(vite@8.1.0(@types/node@24.13.2)(esbuild@0.28.1)(tsx@4.22.4)): dependencies: '@vitest/expect': 4.1.9 - '@vitest/mocker': 4.1.9(vite@8.1.0(esbuild@0.28.1)(tsx@4.22.4)) + '@vitest/mocker': 4.1.9(vite@8.1.0(@types/node@24.13.2)(esbuild@0.28.1)(tsx@4.22.4)) '@vitest/pretty-format': 4.1.9 '@vitest/runner': 4.1.9 '@vitest/snapshot': 4.1.9 @@ -3429,19 +3445,21 @@ snapshots: tinyexec: 1.2.4 tinyglobby: 0.2.17 tinyrainbow: 3.1.0 - vite: 8.1.0(esbuild@0.28.1)(tsx@4.22.4) + vite: 8.1.0(@types/node@24.13.2)(esbuild@0.28.1)(tsx@4.22.4) why-is-node-running: 2.3.0 + optionalDependencies: + '@types/node': 24.13.2 transitivePeerDependencies: - msw - void@0.9.3(arktype@2.2.1)(kysely@0.29.2)(valibot@1.4.1(typescript@6.0.3))(vite@8.1.0(esbuild@0.28.1)(tsx@4.22.4))(vitest@4.1.9(vite@8.1.0(esbuild@0.28.1)(tsx@4.22.4)))(workerd@1.20260625.1)(zod@4.4.3): + void@0.9.3(arktype@2.2.1)(kysely@0.29.2)(valibot@1.4.1(typescript@6.0.3))(vite@8.1.0(@types/node@24.13.2)(esbuild@0.28.1)(tsx@4.22.4))(vitest@4.1.9(@types/node@24.13.2)(vite@8.1.0(@types/node@24.13.2)(esbuild@0.28.1)(tsx@4.22.4)))(workerd@1.20260625.1)(zod@4.4.3): dependencies: '@cloudflare/sandbox': 0.10.3 - '@cloudflare/vite-plugin': 1.42.3(vite@8.1.0(esbuild@0.28.1)(tsx@4.22.4))(workerd@1.20260625.1)(wrangler@4.105.0(@cloudflare/workers-types@4.20260626.1)) + '@cloudflare/vite-plugin': 1.42.3(vite@8.1.0(@types/node@24.13.2)(esbuild@0.28.1)(tsx@4.22.4))(workerd@1.20260625.1)(wrangler@4.105.0(@cloudflare/workers-types@4.20260626.1)) '@cloudflare/workers-types': 4.20260626.1 '@hono/oauth-providers': 0.8.5(hono@4.12.27) '@napi-rs/keyring': 1.3.0 - better-auth: 1.6.22(@cloudflare/workers-types@4.20260626.1)(better-sqlite3@12.11.1)(drizzle-kit@0.31.10)(drizzle-orm@0.45.2(@cloudflare/workers-types@4.20260626.1)(better-sqlite3@12.11.1)(kysely@0.29.2)(pg@8.22.0))(pg@8.22.0)(vitest@4.1.9(vite@8.1.0(esbuild@0.28.1)(tsx@4.22.4))) + better-auth: 1.6.22(@cloudflare/workers-types@4.20260626.1)(better-sqlite3@12.11.1)(drizzle-kit@0.31.10)(drizzle-orm@0.45.2(@cloudflare/workers-types@4.20260626.1)(better-sqlite3@12.11.1)(kysely@0.29.2)(pg@8.22.0))(pg@8.22.0)(vitest@4.1.9(@types/node@24.13.2)(vite@8.1.0(@types/node@24.13.2)(esbuild@0.28.1)(tsx@4.22.4))) better-sqlite3: 12.11.1 blake3-jit: 1.1.0 drizzle-arktype: 0.1.3(arktype@2.2.1)(drizzle-orm@0.45.2(@cloudflare/workers-types@4.20260626.1)(better-sqlite3@12.11.1)(kysely@0.29.2)(pg@8.22.0)) @@ -3454,7 +3472,7 @@ snapshots: ignore: 7.0.5 jsonc-parser: 3.3.1 pg: 8.22.0 - vite: 8.1.0(esbuild@0.28.1)(tsx@4.22.4) + vite: 8.1.0(@types/node@24.13.2)(esbuild@0.28.1)(tsx@4.22.4) wrangler: 4.105.0(@cloudflare/workers-types@4.20260626.1) optionalDependencies: arktype: 2.2.1 diff --git a/scripts/warm.mjs b/scripts/warm.mjs index df205af..898ada6 100644 --- a/scripts/warm.mjs +++ b/scripts/warm.mjs @@ -1,17 +1,25 @@ #!/usr/bin/env node -// Publish one or more preview refs to the bridge by running the publish action -// for each (build + hash + upload the artifacts in Node, then register the ref). -// This is the same code path vite-plus's CI uses; here it's a manual helper to -// seed or refresh a specific commit (refs are otherwise registered dynamically -// by CI, so a normal deploy needs no warming). +// Publish one preview ref to the bridge by running the publish action (pack + +// rewrite + hash + upload in Node, then register the ref). This is the same +// code path vite-plus's CI uses; here it's a manual helper to seed or refresh +// a specific commit, e.g. for incident recovery (refs are otherwise registered +// dynamically by CI, so a normal deploy needs no warming). +// +// The action packs LOCAL package directories (it does not download from +// pkg.pr.new), so this needs a vite-plus checkout at that exact commit, built +// the way the publish workflow builds it: `pnpm install`, dist/binaries in +// place, and `publish-native-addons.ts --mode pkg-pr-new` run. One sha per +// invocation, since a checkout holds one commit's artifacts. // // Usage: -// node scripts/warm.mjs [...] # publish each commit +// node scripts/warm.mjs --repo [--packages ""] // // Needs PKG_PR_BRIDGE_ADMIN_TOKEN (or ADMIN_TOKEN) in the environment. import { spawn } from 'node:child_process' +import fs from 'node:fs' import path from 'node:path' import { fileURLToPath } from 'node:url' +import { parseArgs } from 'node:util' import { readConfig, normalizeSha } from './lib/config.mjs' const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..') @@ -19,13 +27,16 @@ const ACTION = path.join(root, '.github/actions/publish-preview/dist/index.mjs') const ADMIN_TOKEN = process.env.PKG_PR_BRIDGE_ADMIN_TOKEN || process.env.ADMIN_TOKEN || '' -function publish(sha, bridge) { +function publish(sha, bridge, repo, packages) { return new Promise((resolve, reject) => { const child = spawn(process.execPath, [ACTION], { stdio: 'inherit', + cwd: repo, env: { ...process.env, INPUT_SHA: sha, + // Omitted unless overridden: the action's default matches vite-plus. + ...(packages ? { INPUT_PACKAGES: packages } : {}), 'INPUT_BRIDGE-URL': bridge, 'INPUT_ADMIN-TOKEN': ADMIN_TOKEN, }, @@ -47,29 +58,48 @@ if (!ADMIN_TOKEN) { process.exit(1) } -const cliArgs = process.argv.slice(2).map((s) => s.trim()).filter(Boolean) -const shas = cliArgs.map((raw) => { - const sha = normalizeSha(raw) - if (!sha) { - console.error(`warm: invalid commit "${raw}" (expected a sha or commit.)`) - process.exit(1) - } - return sha -}) +let repo = '' +let packages = '' +let positional = [] +try { + const { values, positionals } = parseArgs({ + options: { + repo: { type: 'string' }, + packages: { type: 'string' }, + }, + allowPositionals: true, + }) + repo = values.repo ?? '' + packages = values.packages ?? '' + positional = positionals +} catch (err) { + console.error(`warm: ${err.message}`) + console.error('usage: node scripts/warm.mjs --repo [--packages ""] ') + process.exit(1) +} -if (shas.length === 0) { - console.log('warm: no commit shas given; nothing to publish (refs register dynamically).') +if (positional.length === 0) { + console.log('warm: no commit sha given; nothing to publish (refs register dynamically).') process.exit(0) } +if (positional.length > 1) { + console.error('warm: one sha per run (a checkout holds one commit\'s artifacts)') + process.exit(1) +} +const sha = normalizeSha(positional[0]) +if (!sha) { + console.error(`warm: invalid commit "${positional[0]}" (expected a sha or commit.)`) + process.exit(1) +} +if (!repo || !fs.existsSync(path.join(repo, 'package.json'))) { + console.error('warm: --repo must point at a built vite-plus checkout at that commit') + process.exit(1) +} -console.log(`warm: ${bridge} (publish ${shas.join(', ')})`) -let failed = false -for (const sha of shas) { - try { - await publish(sha, bridge) - } catch (err) { - failed = true - console.error(`warm: ${err.message}`) - } +console.log(`warm: ${bridge} (publish ${sha} from ${repo})`) +try { + await publish(sha, bridge, path.resolve(repo), packages) +} catch (err) { + console.error(`warm: ${err.message}`) + process.exit(1) } -process.exit(failed ? 1 : 0) diff --git a/src/tarball/buildPreviewTarball.ts b/src/tarball/buildPreviewTarball.ts index 6e399b2..a7307aa 100644 --- a/src/tarball/buildPreviewTarball.ts +++ b/src/tarball/buildPreviewTarball.ts @@ -130,10 +130,11 @@ export async function buildPreviewTarball( packageName: string, version: string, env: RewriteEnv, + batch?: ReadonlySet, ): Promise { const { files, pkgEntry, pkg } = await parsePackageJson(gzippedTarball) - const rewritten = rewritePackageJson(pkg, packageName, version, env) + const rewritten = rewritePackageJson(pkg, packageName, version, env, batch) const rewrittenBytes = encodePackageJson(rewritten) const out: TarFileInput[] = [] diff --git a/src/tarball/rewritePackageJson.ts b/src/tarball/rewritePackageJson.ts index f492317..965a2d2 100644 --- a/src/tarball/rewritePackageJson.ts +++ b/src/tarball/rewritePackageJson.ts @@ -1,15 +1,31 @@ import { isWorkspacePackage, PREVIEW_PACKAGES } from '../preview/packages' import { shaToVersion } from '../preview/parsePreviewVersion' -/** Config needed to rebuild pkg.pr.new URLs as bridge URLs. */ +/** + * Config needed to rewrite preview manifests. The pkg.pr.new fields only + * matter on the Worker's fallback path, which rebuilds upstream tarballs + * whose deps are pkg.pr.new URLs; the publish action packs local directories + * (whose manifests never contain such URLs) and omits them. + */ export interface RewriteEnv { PUBLIC_BASE_URL: string - PKG_PR_NEW_BASE: string - PREVIEW_OWNER: string - PREVIEW_REPO: string + PKG_PR_NEW_BASE?: string + PREVIEW_OWNER?: string + PREVIEW_REPO?: string WORKSPACE_PACKAGES: string } +/** + * The manifest fields whose dependency specs are rewritten, and that the + * publish action's batch validation checks. One home for this knowledge so + * the up-front check can never drift from what actually gets pinned. + */ +export const DEPENDENCY_FIELDS = [ + 'dependencies', + 'peerDependencies', + 'optionalDependencies', +] as const + /** * Convert a direct pkg.pr.new dependency URL for the configured repo into the * equivalent synthetic version string (e.g. `0.0.0-commit.`), so @@ -23,6 +39,9 @@ export function pkgPrNewUrlToVersion( spec: string, env: RewriteEnv, ): string | null { + if (!env.PKG_PR_NEW_BASE || !env.PREVIEW_OWNER || !env.PREVIEW_REPO) { + return null + } const prefix = `${env.PKG_PR_NEW_BASE}/${env.PREVIEW_OWNER}/${env.PREVIEW_REPO}/` if (!spec.startsWith(prefix)) return null const rest = spec.slice(prefix.length) @@ -38,7 +57,8 @@ export function pkgPrNewUrlToVersion( /** * Rewrite a dependency map: - * - preview packages (vite-plus, core) -> the synthetic preview version, + * - packages publishing together as this synthetic version (`pinned`) -> the + * synthetic preview version, * - direct pkg.pr.new URLs -> the synthetic version string for that ref, * - everything else -> unchanged. */ @@ -46,11 +66,12 @@ function rewriteDependencies( deps: Record | undefined, version: string, env: RewriteEnv, + pinned: ReadonlySet, ): Record | undefined { if (!deps) return deps const next = { ...deps } for (const [name, spec] of Object.entries(deps)) { - if (PREVIEW_PACKAGES.has(name)) { + if (pinned.has(name)) { next[name] = version continue } @@ -62,32 +83,30 @@ function rewriteDependencies( /** * Rewrite an upstream `package/package.json` for the synthetic preview release: - * set name/version and rewrite preview/pkg.pr.new dependencies. The package is - * NOT renamed to `vite` (npm alias semantics handle that in the consumer's - * dependency spec). + * set name/version and rewrite preview/pkg.pr.new dependencies. Deps on `batch` + * members are pinned to the synthetic version: the publish action passes the + * names it is publishing together (whose validation guarantees every workspace + * dep is covered); the Worker's fallback path passes nothing and pins the + * preview packages, its implicit batch (platform-binary deps arrive there as + * pkg.pr.new URLs instead). The package is NOT renamed to `vite` (npm alias + * semantics handle that in the consumer's dependency spec). */ export function rewritePackageJson( pkg: Record, packageName: string, version: string, env: RewriteEnv, + batch?: ReadonlySet, ): Record { + const pinned = batch ?? PREVIEW_PACKAGES const next: Record = { ...pkg } next.name = packageName next.version = version - if (next.dependencies) { - next.dependencies = rewriteDependencies(next.dependencies, version, env) - } - if (next.peerDependencies) { - next.peerDependencies = rewriteDependencies(next.peerDependencies, version, env) - } - if (next.optionalDependencies) { - next.optionalDependencies = rewriteDependencies( - next.optionalDependencies, - version, - env, - ) + for (const field of DEPENDENCY_FIELDS) { + if (next[field]) { + next[field] = rewriteDependencies(next[field], version, env, pinned) + } } return next diff --git a/test/action/localPack.test.ts b/test/action/localPack.test.ts new file mode 100644 index 0000000..81c887b --- /dev/null +++ b/test/action/localPack.test.ts @@ -0,0 +1,193 @@ +import { execFileSync } from 'node:child_process' +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterAll, beforeAll, describe, expect, it } from 'vitest' +import { + assertValidBatch, + expandPackageDirs, + packDirectory, + parsePackagesInput, + readManifest, +} from '../../.github/actions/publish-preview/src/localPack' +import { buildPreviewTarball } from '../../src/tarball/buildPreviewTarball' +import type { RewriteEnv } from '../../src/tarball/rewritePackageJson' + +const SHA = '6acea1aa818e96365b5811d47360367ba18a3a05' +const VERSION = `0.0.0-commit.${SHA}` + +// The action's env: no pkg.pr.new fields, locally packed manifests never +// carry pkg.pr.new URL deps. +const env: RewriteEnv = { + PUBLIC_BASE_URL: 'https://bridge.example.com', + WORKSPACE_PACKAGES: 'vite-plus,@voidzero-dev/vite-plus-*', +} + +let root: string + +function writeJson(path: string, value: unknown): void { + writeFileSync(path, `${JSON.stringify(value, null, 2)}\n`) +} + +// A minimal pnpm workspace mirroring the vite-plus layout: two members (cli +// depends on prompts via workspace:*, a member that is NOT in +// PREVIEW_PACKAGES, so only batch rewriting can pin it) and a standalone +// platform dir that is not a workspace member (like packages/cli/npm/*). +// Only workspace-internal deps, so `pnpm install` needs no network. +beforeAll(() => { + root = mkdtempSync(join(tmpdir(), 'bridge-action-test-')) + writeFileSync(join(root, 'pnpm-workspace.yaml'), 'packages:\n - packages/*\n') + writeJson(join(root, 'package.json'), { + name: 'fixture-root', + version: '0.0.0', + private: true, + }) + + mkdirSync(join(root, 'packages/prompts'), { recursive: true }) + writeJson(join(root, 'packages/prompts/package.json'), { + name: '@voidzero-dev/vite-plus-prompts', + version: '0.2.2', + files: ['index.js'], + }) + writeFileSync(join(root, 'packages/prompts/index.js'), 'module.exports = 1\n') + + mkdirSync(join(root, 'packages/cli'), { recursive: true }) + writeJson(join(root, 'packages/cli/package.json'), { + name: 'vite-plus', + version: '0.2.2', + files: ['index.js'], + dependencies: { '@voidzero-dev/vite-plus-prompts': 'workspace:*' }, + }) + writeFileSync(join(root, 'packages/cli/index.js'), 'module.exports = 2\n') + + // A platform package dir that is not a workspace member, plus a dir without + // a package.json (napi's createNpmDirs can leave those) the glob must skip. + mkdirSync(join(root, 'packages/cli/npm/darwin-arm64'), { recursive: true }) + writeJson(join(root, 'packages/cli/npm/darwin-arm64/package.json'), { + name: '@voidzero-dev/vite-plus-darwin-arm64', + version: '0.2.2', + os: ['darwin'], + cpu: ['arm64'], + files: ['addon.node'], + }) + writeFileSync(join(root, 'packages/cli/npm/darwin-arm64/addon.node'), 'bin\n') + mkdirSync(join(root, 'packages/cli/npm/empty'), { recursive: true }) + + execFileSync('pnpm', ['install', '--ignore-scripts', '--silent'], { cwd: root }) +}, 120_000) + +afterAll(() => { + rmSync(root, { recursive: true, force: true }) +}) + +describe('parsePackagesInput', () => { + it('splits on newlines and commas, dropping blanks', () => { + expect( + parsePackagesInput('packages/cli\n packages/core ,packages/cli/npm/*\n\n'), + ).toEqual(['packages/cli', 'packages/core', 'packages/cli/npm/*']) + }) +}) + +describe('expandPackageDirs', () => { + it('expands globs to package dirs and keeps explicit dirs', () => { + const dirs = expandPackageDirs(['packages/cli', 'packages/cli/npm/*'], root) + expect(dirs).toEqual([ + join(root, 'packages/cli'), + join(root, 'packages/cli/npm/darwin-arm64'), + ]) + expect(readManifest(dirs[1]).name).toBe('@voidzero-dev/vite-plus-darwin-arm64') + }) + + it('dedupes overlapping patterns', () => { + const dirs = expandPackageDirs(['packages/*', 'packages/cli'], root) + expect(dirs).toEqual([ + join(root, 'packages/cli'), + join(root, 'packages/prompts'), + ]) + }) + + it('fails loudly on an explicit dir without a package.json', () => { + expect(() => expandPackageDirs(['packages/nope'], root)).toThrow( + /no package\.json/, + ) + expect(() => expandPackageDirs(['packages/nope/*'], root)).toThrow( + /matched no directory/, + ) + }) +}) + +describe('assertValidBatch', () => { + const pkg = (name: string, deps?: Record) => ({ + dir: `/fixture/${name}`, + manifest: { name, version: '0.2.2', ...(deps ? { dependencies: deps } : {}) }, + }) + + it('returns the batch names when every workspace dep is covered', () => { + const batch = assertValidBatch( + [pkg('vite-plus', { '@voidzero-dev/vite-plus-prompts': '0.2.2', picomatch: '^2.3.1' }), pkg('@voidzero-dev/vite-plus-prompts')], + env, + ) + expect(batch).toEqual(new Set(['vite-plus', '@voidzero-dev/vite-plus-prompts'])) + }) + + it('rejects an empty batch, non-workspace names, and duplicates', () => { + expect(() => assertValidBatch([], env)).toThrow(/matched no package/) + expect(() => assertValidBatch([pkg('left-pad')], env)).toThrow( + /not an allowed workspace package/, + ) + expect(() => + assertValidBatch([pkg('vite-plus'), pkg('vite-plus')], env), + ).toThrow(/duplicate package/) + }) + + it('rejects a workspace dep missing from the batch', () => { + expect(() => + assertValidBatch( + [pkg('vite-plus', { '@voidzero-dev/vite-plus-prompts': '0.2.2' })], + env, + ), + ).toThrow(/not in this publish batch/) + }) +}) + +describe('packDirectory', () => { + it('packs a workspace member; the batch rewrite pins its workspace dep', async () => { + const packed = await packDirectory(join(root, 'packages/cli')) + + // pnpm pack resolved `workspace:*` to the concrete version; without a + // batch the rewrite leaves that non-preview workspace dep alone. + const solo = await buildPreviewTarball(packed, 'vite-plus', VERSION, env) + expect(solo.packageJson.dependencies['@voidzero-dev/vite-plus-prompts']).toBe( + '0.2.2', + ) + + // Publishing prompts in the same batch pins the dep to the synthetic + // version, so the whole batch resolves through the bridge. + const build = await buildPreviewTarball( + packed, + 'vite-plus', + VERSION, + env, + new Set(['vite-plus', '@voidzero-dev/vite-plus-prompts']), + ) + expect(build.packageJson.version).toBe(VERSION) + expect(build.packageJson.dependencies['@voidzero-dev/vite-plus-prompts']).toBe( + VERSION, + ) + expect(build.integrity).toMatch(/^sha512-/) + expect(build.shasum).toMatch(/^[0-9a-f]{40}$/) + }, 60_000) + + it('packs a standalone platform dir that is not a workspace member', async () => { + const packed = await packDirectory(join(root, 'packages/cli/npm/darwin-arm64')) + const build = await buildPreviewTarball( + packed, + '@voidzero-dev/vite-plus-darwin-arm64', + VERSION, + env, + ) + expect(build.packageJson.version).toBe(VERSION) + expect(build.packageJson.os).toEqual(['darwin']) + expect(build.packageJson.cpu).toEqual(['arm64']) + }, 60_000) +}) diff --git a/test/unit.test.ts b/test/unit.test.ts index eb34f28..e300dae 100644 --- a/test/unit.test.ts +++ b/test/unit.test.ts @@ -309,6 +309,61 @@ describe('rewritePackageJson', () => { `0.0.0-commit.${sha}`, ) }) + + it('pins deps on batch members to the synthetic version', () => { + // The publish action packs local directories, so batch-internal deps + // arrive as plain workspace versions (pnpm pack resolved `workspace:*`), + // not pkg.pr.new URLs. + const out = rewritePackageJson( + { + name: 'vite-plus', + version: '0.2.2', + dependencies: { + '@voidzero-dev/vite-plus-core': '0.2.2', + picomatch: '^2.3.1', + }, + optionalDependencies: { + '@voidzero-dev/vite-plus-darwin-arm64': '0.2.2', + }, + }, + 'vite-plus', + `0.0.0-commit.${sha}`, + env, + new Set([ + 'vite-plus', + '@voidzero-dev/vite-plus-core', + '@voidzero-dev/vite-plus-darwin-arm64', + ]), + ) + expect(out.version).toBe(`0.0.0-commit.${sha}`) + expect(out.dependencies['@voidzero-dev/vite-plus-core']).toBe( + `0.0.0-commit.${sha}`, + ) + expect(out.dependencies.picomatch).toBe('^2.3.1') + expect(out.optionalDependencies['@voidzero-dev/vite-plus-darwin-arm64']).toBe( + `0.0.0-commit.${sha}`, + ) + }) + + it('leaves non-batch workspace versions alone without a batch', () => { + // Worker path (no batchPackages): a plain version dep on a non-preview + // workspace package stays as-is; only pkg.pr.new URLs are routed. + const out = rewritePackageJson( + { + name: 'vite-plus', + version: '0.2.2', + optionalDependencies: { + '@voidzero-dev/vite-plus-darwin-arm64': '0.2.2', + }, + }, + 'vite-plus', + `0.0.0-commit.${sha}`, + env, + ) + expect(out.optionalDependencies['@voidzero-dev/vite-plus-darwin-arm64']).toBe( + '0.2.2', + ) + }) }) describe('validateTarballPath', () => { diff --git a/tsconfig.action.json b/tsconfig.action.json new file mode 100644 index 0000000..3d51745 --- /dev/null +++ b/tsconfig.action.json @@ -0,0 +1,28 @@ +{ + // The publish action and its tests run in Node (GitHub runner), not the + // Worker, so they typecheck against Node types instead of `void/env`. The + // Worker modules the action imports are checked transitively; they only use + // web-standard globals present in both runtimes. + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "Bundler", + "lib": [ + "ES2022" + ], + "types": [ + "node" + ], + "strict": true, + "skipLibCheck": true, + "noEmit": true, + "isolatedModules": true, + "esModuleInterop": true, + "resolveJsonModule": true, + "forceConsistentCasingInFileNames": true + }, + "include": [ + ".github/actions/publish-preview/src", + "test/action" + ] +} diff --git a/tsconfig.json b/tsconfig.json index 90a6551..2d3ec5f 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -26,5 +26,10 @@ "test", "vite.config.ts", "vitest.config.ts" + ], + // The action tests run in Node, not the Worker: tsconfig.action.json checks + // them (and the action source) against Node types. + "exclude": [ + "test/action" ] } diff --git a/vitest.config.ts b/vitest.config.ts index 306be30..25fc933 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -2,16 +2,34 @@ import { defineConfig } from 'vitest/config' import { cloudflareTest } from '@cloudflare/vitest-pool-workers' export default defineConfig({ - plugins: [ - cloudflareTest({ - wrangler: { configPath: './wrangler.test.jsonc' }, - miniflare: { - // Test overrides for the bindings declared in wrangler.test.jsonc. - bindings: { - PUBLIC_BASE_URL: 'https://bridge.example.com', - ADMIN_TOKEN: 'test-admin-token', + test: { + projects: [ + { + plugins: [ + cloudflareTest({ + wrangler: { configPath: './wrangler.test.jsonc' }, + miniflare: { + // Test overrides for the bindings declared in wrangler.test.jsonc. + bindings: { + PUBLIC_BASE_URL: 'https://bridge.example.com', + ADMIN_TOKEN: 'test-admin-token', + }, + }, + }), + ], + test: { + name: 'worker', + include: ['test/*.test.ts'], }, }, - }), - ], + { + // The publish action runs in Node and shells out to `pnpm pack`, + // which the workers pool cannot; its tests run in a plain Node project. + test: { + name: 'action', + include: ['test/action/*.test.ts'], + }, + }, + ], + }, })