450 lines · 15.5 KB · TypeScript
RawDownload
#!/usr/bin/env bun/** * Publishes the public @executor-js/* workspace packages to npm. * * Walks a hard-coded list of publishable package directories, determines the * dist-tag from the version string (anything containing `-` is treated as beta), * and packs + publishes each package whose current version is not already on npm. * * Invoked from `.github/workflows/release.yml` via the `publish:` input on * changesets/action after the Version Packages PR has been merged, and locally * via `bun run release:publish:packages` (or `--dry-run`). */import { $ } from "bun";import { existsSync } from "node:fs";import { readdir, readFile, rm, writeFile } from "node:fs/promises";import { dirname, join, resolve } from "node:path";import { fileURLToPath } from "node:url"; type Channel = "latest" | "beta"; const repoRoot = resolve(dirname(fileURLToPath(import.meta.url)), ".."); const PACKAGE_SCOPE = "@executor-js"; /** * Workspace-relative paths of the public packages. Kept explicit so a new * directory under `packages/plugins/` does not accidentally ship to npm. */const PUBLIC_PACKAGE_DIRS = [  "packages/core/fumadb",  "packages/kernel/core",  "packages/kernel/runtime-quickjs",  "packages/core/sdk",  "packages/core/config",  "packages/core/execution",  "packages/core/cli",  "packages/plugins/example",  "packages/plugins/file-secrets",  "packages/plugins/graphql",  "packages/plugins/keychain",  "packages/plugins/mcp",  "packages/plugins/onepassword",  "packages/plugins/openapi",] as const; const parseArgs = (argv: ReadonlyArray<string>): { dryRun: boolean; prepareOnly: boolean } => {  let dryRun = false;  let prepareOnly = false;  for (const arg of argv) {    if (arg === "--dry-run") {      dryRun = true;      continue;    }    if (arg === "--prepare-only") {      prepareOnly = true;      continue;    }    throw new Error(`Unknown argument: ${arg}`);  }  return { dryRun, prepareOnly };}; const resolveChannel = (version: string): Channel => (version.includes("-") ? "beta" : "latest"); const readPackageMeta = async (pkgDir: string) => {  const pkgJsonPath = join(pkgDir, "package.json");  const pkg = (await Bun.file(pkgJsonPath).json()) as {    name?: string;    version?: string;    private?: boolean;  };   if (!pkg.name || !pkg.version) {    throw new Error(`Missing name/version in ${pkgJsonPath}`);  }  if (pkg.private === true) {    throw new Error(`${pkg.name} is marked private and cannot be published`);  }   return { name: pkg.name, version: pkg.version };}; const packageAlreadyPublished = async (name: string, version: string): Promise<boolean> => {  const proc = Bun.spawn(["npm", "view", `${name}@${version}`, "version"], {    stdio: ["ignore", "ignore", "ignore"],  });  return (await proc.exited) === 0;}; type DependencyBlock = Record<string, string>;type PeerDependenciesMeta = Record<string, { optional?: boolean }>;type MutablePackageJson = {  name?: string;  dependencies?: DependencyBlock;  devDependencies?: DependencyBlock;  peerDependencies?: DependencyBlock;  peerDependenciesMeta?: PeerDependenciesMeta;  optionalDependencies?: DependencyBlock;  [key: string]: unknown;}; /** * Resolves `workspace:*` dependencies between public packages to concrete * versions before packing. Returns a restore function that reverts package.json. * * Workspace-only `@executor-js/*` peer deps (e.g. `@executor-js/api`, * `@executor-js/react`) that aren't in `publishable` are stripped from * `peerDependencies` (and `peerDependenciesMeta`) entirely — they don't * exist on npm, so leaving them in the packed manifest would emit * install-time warnings for unresolvable packages. */const applyWorkspaceVersions = async (  pkgDir: string,  publishable: ReadonlySet<string>,  publishableVersions: ReadonlyMap<string, string>,): Promise<() => Promise<void>> => {  const isInternalScope = (key: string): boolean => key.startsWith(`${PACKAGE_SCOPE}/`);   const renameDepBlock = (block: DependencyBlock | undefined): DependencyBlock | undefined => {    if (!block) return block;    const next: DependencyBlock = {};    let mutated = false;    for (const [key, value] of Object.entries(block)) {      if (publishable.has(key) && value.startsWith("workspace:")) {        next[key] = publishableVersions.get(key) ?? value;        mutated = true;      } else if (isInternalScope(key) && !publishable.has(key)) {        // Workspace-only `@executor-js/*` regular dep that we don't        // publish (e.g. `@executor-js/api`). Strip it: it's not in the        // shipped runtime entries (those imports live in        // `src/api/*` / `src/react/*` which don't make it into the        // packed dist), and leaving it in would 404 at install time.        mutated = true;      } else {        next[key] = value;      }    }    return mutated ? next : block;  };   /**   * Peer-deps variant of `renameDepBlock`: resolve workspace specifiers for   * publishable peers, but DROP non-publishable `@executor-js/*` peers.   * They reference workspace-only packages (`@executor-js/api`,   * `@executor-js/react`) that don't exist on npm, so leaving them in   * the packed manifest emits install-time warnings for unresolvable   * packages. Non-`@executor-js` peers (`react`, `@tanstack/*`,   * `@effect-atom/*`, etc.) are real npm packages and pass through   * unchanged.   */  const renamePeerDepBlock = (block: DependencyBlock | undefined): DependencyBlock | undefined => {    if (!block) return block;    const next: DependencyBlock = {};    let mutated = false;    for (const [key, value] of Object.entries(block)) {      if (publishable.has(key)) {        next[key] = value.startsWith("workspace:")          ? (publishableVersions.get(key) ?? value)          : value;        if (next[key] !== value) mutated = true;      } else if (isInternalScope(key)) {        // Workspace-only `@executor-js/*` peer that we don't publish —        // strip it so the packed tarball doesn't reference an        // npm package that doesn't exist.        mutated = true;      } else {        next[key] = value;      }    }    return mutated ? next : block;  };   /**   * Strips `peerDependenciesMeta` entries that target an   * `@executor-js/*` peer we don't publish, mirroring `renamePeerDepBlock`   * so the meta block can't drift out of sync with the deps block.   */  const renamePeerMetaBlock = (    block: PeerDependenciesMeta | undefined,  ): PeerDependenciesMeta | undefined => {    if (!block) return block;    const next: PeerDependenciesMeta = {};    let mutated = false;    for (const [key, value] of Object.entries(block)) {      if (publishable.has(key)) {        next[key] = value;      } else if (isInternalScope(key)) {        mutated = true;      } else {        next[key] = value;      }    }    return mutated ? next : block;  };   const pkgJsonPath = join(pkgDir, "package.json");  const original = await readFile(pkgJsonPath, "utf8");  const pkg = JSON.parse(original) as MutablePackageJson;  pkg.dependencies = renameDepBlock(pkg.dependencies);  pkg.devDependencies = renameDepBlock(pkg.devDependencies);  pkg.peerDependencies = renamePeerDepBlock(pkg.peerDependencies);  pkg.peerDependenciesMeta = renamePeerMetaBlock(pkg.peerDependenciesMeta);  pkg.optionalDependencies = renameDepBlock(pkg.optionalDependencies);  const pkgNext = `${JSON.stringify(pkg, null, 2)}\n`;  if (pkgNext !== original) {    await writeFile(pkgJsonPath, pkgNext);    return async () => {      await writeFile(pkgJsonPath, original);    };  }  return async () => {};}; /** * Applies `publishConfig` field overrides to package.json in place, returning a * function that restores the original file. `bun pm pack` does not substitute * `publishConfig.exports` / `publishConfig.main` etc at pack time (npm does, * but only for a subset of fields and only for `npm pack`), so we rewrite the * file ourselves so the packed tarball has the correct `exports` pointing at * `dist/` instead of the dev-time `src/index.ts`. */const applyPublishConfig = async (pkgDir: string): Promise<() => Promise<void>> => {  const pkgJsonPath = join(pkgDir, "package.json");  const original = await readFile(pkgJsonPath, "utf8");  const parsed = JSON.parse(original) as {    publishConfig?: Record<string, unknown>;    [key: string]: unknown;  };   const publishConfig = parsed.publishConfig;  if (!publishConfig || typeof publishConfig !== "object") {    return async () => {};  }   // Fields we allow publishConfig to override. `access`/`tag`/`registry` are  // real npm publish-time config keys — they must NOT be hoisted into the  // top-level manifest.  const overridable = new Set([    "exports",    "main",    "module",    "types",    "typings",    "bin",    "browser",    "files",  ]);   const nextPublishConfig: Record<string, unknown> = {};  let mutated = false;  for (const [key, value] of Object.entries(publishConfig)) {    if (overridable.has(key)) {      parsed[key] = value;      mutated = true;    } else {      nextPublishConfig[key] = value;    }  }   if (!mutated) {    return async () => {};  }   if (Object.keys(nextPublishConfig).length === 0) {    delete parsed.publishConfig;  } else {    parsed.publishConfig = nextPublishConfig;  }   await writeFile(pkgJsonPath, `${JSON.stringify(parsed, null, 2)}\n`);  return async () => {    await writeFile(pkgJsonPath, original);  };}; type PackedPackage = {  readonly pkgDir: string;  readonly name: string;  readonly version: string;  readonly channel: Channel;  readonly tarball: string;}; const packPackage = async (  pkgDir: string,  dryRun: boolean,  publishable: ReadonlySet<string>,  publishableVersions: ReadonlyMap<string, string>,): Promise<PackedPackage> => {  const { name, version } = await readPackageMeta(pkgDir);  const channel = resolveChannel(version);   if (!existsSync(join(pkgDir, "dist"))) {    throw new Error(`Missing dist/ in ${pkgDir}. Did you run 'bun run build:packages'?`);  }   console.log(`[pack] ${name}@${version} (${channel})${dryRun ? " [dry-run]" : ""}`);   // Clean any stale tarballs from previous runs so our readdir finds exactly  // the archive produced by the pack below.  const stale = (await readdir(pkgDir)).filter((entry) => entry.endsWith(".tgz"));  for (const entry of stale) {    await rm(join(pkgDir, entry), { force: true });  }   const restoreWorkspaceVersions = await applyWorkspaceVersions(    pkgDir,    publishable,    publishableVersions,  );  const restorePublishConfig = await applyPublishConfig(pkgDir);  try {    await $`bun pm pack`.cwd(pkgDir);  } finally {    await restorePublishConfig();    await restoreWorkspaceVersions();  }   const produced = (await readdir(pkgDir)).filter((entry) => entry.endsWith(".tgz"));  if (produced.length !== 1) {    throw new Error(      `Expected exactly 1 .tgz in ${pkgDir}, found ${produced.length}: ${produced.join(", ")}`,    );  }   return { pkgDir, name, version, channel, tarball: produced[0]! };}; const publishPacked = async (packed: PackedPackage) => {  // Skip publishing already-shipped versions. The pack still ran above so  // smoke tests / pkg-pr-new previews always have a fresh tarball.  if (await packageAlreadyPublished(packed.name, packed.version)) {    console.log(`[skip] ${packed.name}@${packed.version} already on npm`);    return;  }   console.log(`[publish] ${packed.name}@${packed.version} (${packed.channel})`);   const args = ["publish", packed.tarball, "--access", "public", "--tag", packed.channel];  if (process.env.GITHUB_ACTIONS === "true") {    args.push("--provenance");  }   // Buffer output and replay it prefixed: several publishes run at once and  // interleaved npm output is unreadable.  const result = await $`npm ${args}`.cwd(packed.pkgDir).nothrow().quiet();  const output = `${result.stdout.toString()}${result.stderr.toString()}`.trim();  if (output.length > 0) {    console.log(output.replace(/^/gm, `[${packed.name}] `));  }  if (result.exitCode !== 0) {    throw new Error(`npm publish failed for ${packed.name} (exit ${result.exitCode})`);  }}; /** Publish concurrently: these are distinct packages, so no two publishes *  touch the same npm packument (the 409 hazard that forces the executor CLI *  platform variants to publish serially does not apply here). Each publish *  is network + sigstore dominated (~15-25s), so this is where the release *  job's minutes go. */const PUBLISH_CONCURRENCY = 4; const publishAll = async (packed: ReadonlyArray<PackedPackage>) => {  const queue = [...packed];  const failures: string[] = [];   const workers = Array.from({ length: Math.min(PUBLISH_CONCURRENCY, queue.length) }, async () => {    for (let next = queue.shift(); next !== undefined; next = queue.shift()) {      try {        await publishPacked(next);      } catch (error) {        failures.push(error instanceof Error ? error.message : String(error));      }    }  });  await Promise.all(workers);   if (failures.length > 0) {    throw new Error(`${failures.length} package(s) failed to publish:\n${failures.join("\n")}`);  }}; /** * Rewrite-in-place mode for the pkg.pr.new preview workflow. pkg-pr-new runs * `bun pm pack` against each workspace package directly, which can't see our * `publishConfig.exports` overrides or resolve `workspace:*` references. This * walks every public package, applies the same rewrites `publishPackage` * does, and intentionally leaves them mutated — the CI job is ephemeral and * the workflow tears down right after pkg-pr-new finishes. */const prepareOnePackage = async (  pkgDir: string,  publishable: ReadonlySet<string>,  publishableVersions: ReadonlyMap<string, string>,) => {  const { name, version } = await readPackageMeta(pkgDir);  if (!existsSync(join(pkgDir, "dist"))) {    throw new Error(`Missing dist/ in ${pkgDir}. Did you run 'bun run build:packages'?`);  }  console.log(`[prepare] ${name}@${version}`);  await applyWorkspaceVersions(pkgDir, publishable, publishableVersions);  await applyPublishConfig(pkgDir);}; const main = async () => {  const { dryRun, prepareOnly } = parseArgs(process.argv.slice(2));   // Each package's own version determines its dist-tag (pre-release versions  // with `-` publish to `beta`, everything else to `latest`). Packages are  // only skipped when their current version is already on npm.  const mode = prepareOnly ? " [prepare-only]" : dryRun ? " [dry-run]" : "";  console.log(`Publishing ${PACKAGE_SCOPE} packages${mode}`);   await $`bun run build:packages`.cwd(repoRoot);   // Snapshot the public package names and versions up front so public  // workspace dependencies can be written as exact versions in packed tarballs.  const publishable = new Set<string>();  const publishableVersions = new Map<string, string>();  for (const relDir of PUBLIC_PACKAGE_DIRS) {    const pkg = await readPackageMeta(join(repoRoot, relDir));    publishable.add(pkg.name);    publishableVersions.set(pkg.name, pkg.version);  }   if (prepareOnly) {    for (const relDir of PUBLIC_PACKAGE_DIRS) {      await prepareOnePackage(join(repoRoot, relDir), publishable, publishableVersions);    }    return;  }   // Pack sequentially: packing rewrites each package.json in place and `bun  // pm pack` resolves against the shared workspace tree, so overlapping packs  // could observe each other's temporary manifests.  const packed: PackedPackage[] = [];  for (const relDir of PUBLIC_PACKAGE_DIRS) {    packed.push(      await packPackage(join(repoRoot, relDir), dryRun, publishable, publishableVersions),    );  }   if (dryRun) {    return;  }   await publishAll(packed);}; await main();