#!/usr/bin/env -S node --import tsx import { execFileSync, execSync } from "node:child_process"; import { mkdtempSync, mkdirSync, readdirSync, readFileSync, rmSync } from "node:fs"; import { tmpdir } from "node:os"; import { join, resolve } from "node:path"; import { pathToFileURL } from "node:url"; import { PACKAGE_DIST_INVENTORY_RELATIVE_PATH, writePackageDistInventory, } from "../src/infra/package-dist-inventory.ts"; import { collectBundledExtensionManifestErrors, type BundledExtension, type ExtensionPackageJson as PackageJson, } from "./lib/bundled-extension-manifest.ts"; import { listBundledPluginPackArtifacts } from "./lib/bundled-plugin-build-entries.mjs"; import { collectBuiltBundledPluginStagedRuntimeDependencyErrors, collectBundledPluginRootRuntimeMirrorErrors, collectBundledPluginRuntimeDependencySpecs, collectRootDistBundledRuntimeMirrors, } from "./lib/bundled-plugin-root-runtime-mirrors.mjs"; import { collectPackUnpackedSizeErrors as collectNpmPackUnpackedSizeErrors } from "./lib/npm-pack-budget.mjs"; import { listPluginSdkDistArtifacts } from "./lib/plugin-sdk-entries.mjs"; import { runInstalledWorkspaceBootstrapSmoke, WORKSPACE_TEMPLATE_PACK_PATHS, } from "./lib/workspace-bootstrap-smoke.mjs"; import { listStaticExtensionAssetOutputs } from "./runtime-postbuild.mjs"; import { sparkleBuildFloorsFromShortVersion, type SparkleBuildFloors } from "./sparkle-build.ts"; export { collectBundledExtensionManifestErrors } from "./lib/bundled-extension-manifest.ts"; export { collectBuiltBundledPluginStagedRuntimeDependencyErrors, collectBundledPluginRootRuntimeMirrorErrors, collectRootDistBundledRuntimeMirrors, packageNameFromSpecifier, } from "./lib/bundled-plugin-root-runtime-mirrors.mjs"; type PackFile = { path: string }; type PackResult = { files?: PackFile[]; filename?: string; unpackedSize?: number }; const requiredPathGroups = [ PACKAGE_DIST_INVENTORY_RELATIVE_PATH, ["dist/index.js", "dist/index.mjs"], ["dist/entry.js", "dist/entry.mjs"], ...listPluginSdkDistArtifacts(), ...listBundledPluginPackArtifacts(), ...listStaticExtensionAssetOutputs(), ...WORKSPACE_TEMPLATE_PACK_PATHS, "scripts/npm-runner.mjs", "scripts/preinstall-package-manager-warning.mjs", "scripts/postinstall-bundled-plugins.mjs", "dist/plugin-sdk/compat.js", "dist/plugin-sdk/root-alias.cjs", "dist/build-info.json", "dist/channel-catalog.json", "dist/control-ui/index.html", "dist/extensions/qa-channel/runtime-api.js", "dist/extensions/qa-lab/runtime-api.js", ]; const legacyUpdateCompatPackPaths = new Set([ "dist/extensions/qa-channel/runtime-api.js", "dist/extensions/qa-lab/runtime-api.js", ]); const forbiddenPrefixes = [ "dist-runtime/", "dist/OpenClaw.app/", "dist/extensions/qa-lab/", "dist/plugin-sdk/extensions/qa-lab/", "dist/plugin-sdk/qa-lab.", "dist/plugin-sdk/qa-runtime.", "dist/plugin-sdk/src/plugin-sdk/qa-lab.d.ts", "dist/plugin-sdk/src/plugin-sdk/qa-runtime.d.ts", "dist/qa-runtime-", "dist/plugin-sdk/.tsbuildinfo", "docs/.generated/", "qa/", ]; const forbiddenPrivateQaContentMarkers = [ "//#region extensions/qa-lab/", "qa-lab/cli.js", "qa-lab/runtime-api.js", ] as const; const forbiddenPrivateQaContentScanPrefixes = ["dist/"] as const; const appcastPath = resolve("appcast.xml"); const laneBuildMin = 1_000_000_000; const laneFloorAdoptionDateKey = 20260227; function collectBundledExtensions(): BundledExtension[] { const extensionsDir = resolve("extensions"); const entries = readdirSync(extensionsDir, { withFileTypes: true }).filter((entry) => entry.isDirectory(), ); return entries.flatMap((entry) => { const packagePath = join(extensionsDir, entry.name, "package.json"); try { return [ { id: entry.name, packageJson: JSON.parse(readFileSync(packagePath, "utf8")) as PackageJson, }, ]; } catch { return []; } }); } function checkBundledExtensionMetadata() { const extensions = collectBundledExtensions(); const manifestErrors = collectBundledExtensionManifestErrors(extensions); const rootPackage = JSON.parse(readFileSync(resolve("package.json"), "utf8")) as { dependencies?: Record; optionalDependencies?: Record; }; const bundledRuntimeDependencySpecs = collectBundledPluginRuntimeDependencySpecs( resolve("extensions"), ); const requiredRootMirrors = collectRootDistBundledRuntimeMirrors({ bundledRuntimeDependencySpecs, distDir: resolve("dist"), }); const rootMirrorErrors = collectBundledPluginRootRuntimeMirrorErrors({ bundledRuntimeDependencySpecs, requiredRootMirrors, rootPackageJson: rootPackage, }); const builtArtifactErrors = collectBuiltBundledPluginStagedRuntimeDependencyErrors({ bundledPluginsDir: resolve("dist/extensions"), }); const errors = [...manifestErrors, ...rootMirrorErrors, ...builtArtifactErrors]; if (errors.length > 0) { console.error("release-check: bundled extension manifest validation failed:"); for (const error of errors) { console.error(` - ${error}`); } process.exit(1); } } function runPackDry(): PackResult[] { const raw = execSync("npm pack --dry-run --json --ignore-scripts", { encoding: "utf8", stdio: ["ignore", "pipe", "pipe"], maxBuffer: 1024 * 1024 * 100, }); return JSON.parse(raw) as PackResult[]; } function runPack(packDestination: string): PackResult[] { const raw = execFileSync( "npm", ["pack", "--json", "--ignore-scripts", "--pack-destination", packDestination], { encoding: "utf8", stdio: ["ignore", "pipe", "pipe"], maxBuffer: 1024 * 1024 * 100, }, ); return JSON.parse(raw) as PackResult[]; } function resolvePackedTarballPath(packDestination: string, results: PackResult[]): string { const filenames = results .map((entry) => entry.filename) .filter((filename): filename is string => typeof filename === "string" && filename.length > 0); if (filenames.length !== 1) { throw new Error( `release-check: npm pack produced ${filenames.length} tarballs; expected exactly one.`, ); } return resolve(packDestination, filenames[0]); } function installPackedTarball(prefixDir: string, tarballPath: string, cwd: string): void { execFileSync( "npm", [ "install", "-g", "--prefix", prefixDir, "--ignore-scripts", "--no-audit", "--no-fund", tarballPath, ], { cwd, encoding: "utf8", stdio: "inherit", }, ); } function resolveGlobalRoot(prefixDir: string, cwd: string): string { return execFileSync("npm", ["root", "-g", "--prefix", prefixDir], { cwd, encoding: "utf8", stdio: ["ignore", "pipe", "pipe"], }).trim(); } function runPackedBundledChannelEntrySmoke(): void { const tmpRoot = mkdtempSync(join(tmpdir(), "openclaw-release-pack-smoke-")); try { const packDir = join(tmpRoot, "pack"); mkdirSync(packDir); const packResults = runPack(packDir); const tarballPath = resolvePackedTarballPath(packDir, packResults); const prefixDir = join(tmpRoot, "prefix"); installPackedTarball(prefixDir, tarballPath, tmpRoot); const packageRoot = join(resolveGlobalRoot(prefixDir, tmpRoot), "openclaw"); execFileSync( process.execPath, [ resolve("scripts/test-built-bundled-channel-entry-smoke.mjs"), "--package-root", packageRoot, ], { stdio: "inherit", env: { ...process.env, OPENCLAW_DISABLE_BUNDLED_ENTRY_SOURCE_FALLBACK: "1", }, }, ); const homeDir = join(tmpRoot, "home"); const stateDir = join(tmpRoot, "state"); mkdirSync(homeDir, { recursive: true }); execFileSync( process.execPath, [join(packageRoot, "openclaw.mjs"), "completion", "--write-state"], { cwd: packageRoot, stdio: "inherit", env: { ...process.env, HOME: homeDir, OPENCLAW_STATE_DIR: stateDir, OPENCLAW_SUPPRESS_NOTES: "1", OPENCLAW_DISABLE_BUNDLED_ENTRY_SOURCE_FALLBACK: "1", }, }, ); const completionFiles = readdirSync(join(stateDir, "completions")).filter( (entry) => !entry.startsWith("."), ); if (completionFiles.length === 0) { throw new Error("release-check: packed completion smoke produced no completion files."); } runInstalledWorkspaceBootstrapSmoke({ packageRoot }); } finally { rmSync(tmpRoot, { recursive: true, force: true }); } } export function collectMissingPackPaths(paths: Iterable): string[] { const available = new Set(paths); return requiredPathGroups .flatMap((group) => { if (Array.isArray(group)) { return group.some((path) => available.has(path)) ? [] : [group.join(" or ")]; } return available.has(group) ? [] : [group]; }) .toSorted((left, right) => left.localeCompare(right)); } export function collectForbiddenPackPaths(paths: Iterable): string[] { return [...paths] .filter( (path) => !legacyUpdateCompatPackPaths.has(path) && (forbiddenPrefixes.some((prefix) => path.startsWith(prefix)) || /node_modules\//.test(path)), ) .toSorted((left, right) => left.localeCompare(right)); } export function collectForbiddenPackContentPaths( paths: Iterable, rootDir = process.cwd(), ): string[] { const textPathPattern = /\.(?:[cm]?js|d\.ts|json|md|mjs|cjs)$/u; return [...paths] .filter((packedPath) => { if (packedPath === PACKAGE_DIST_INVENTORY_RELATIVE_PATH) { return false; } if (!forbiddenPrivateQaContentScanPrefixes.some((prefix) => packedPath.startsWith(prefix))) { return false; } if (!textPathPattern.test(packedPath)) { return false; } let content: string; try { content = readFileSync(resolve(rootDir, packedPath), "utf8"); } catch { return false; } return forbiddenPrivateQaContentMarkers.some((marker) => content.includes(marker)); }) .toSorted((left, right) => left.localeCompare(right)); } export { collectPackUnpackedSizeErrors } from "./lib/npm-pack-budget.mjs"; function extractTag(item: string, tag: string): string | null { const escapedTag = tag.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); const regex = new RegExp(`<${escapedTag}>([^<]+)`); return regex.exec(item)?.[1]?.trim() ?? null; } export function collectAppcastSparkleVersionErrors(xml: string): string[] { const itemMatches = [...xml.matchAll(/([\s\S]*?)<\/item>/g)]; const errors: string[] = []; const calverItems: Array<{ title: string; sparkleBuild: number; floors: SparkleBuildFloors }> = []; if (itemMatches.length === 0) { errors.push("appcast.xml contains no entries."); } for (const [, item] of itemMatches) { const title = extractTag(item, "title") ?? "unknown"; const shortVersion = extractTag(item, "sparkle:shortVersionString"); const sparkleVersion = extractTag(item, "sparkle:version"); if (!sparkleVersion) { errors.push(`appcast item '${title}' is missing sparkle:version.`); continue; } if (!/^[0-9]+$/.test(sparkleVersion)) { errors.push(`appcast item '${title}' has non-numeric sparkle:version '${sparkleVersion}'.`); continue; } if (!shortVersion) { continue; } const floors = sparkleBuildFloorsFromShortVersion(shortVersion); if (floors === null) { continue; } calverItems.push({ title, sparkleBuild: Number(sparkleVersion), floors }); } const observedLaneAdoptionDateKey = calverItems .filter((item) => item.sparkleBuild >= laneBuildMin) .map((item) => item.floors.dateKey) .toSorted((a, b) => a - b)[0]; const effectiveLaneAdoptionDateKey = typeof observedLaneAdoptionDateKey === "number" ? Math.min(observedLaneAdoptionDateKey, laneFloorAdoptionDateKey) : laneFloorAdoptionDateKey; for (const item of calverItems) { const expectLaneFloor = item.sparkleBuild >= laneBuildMin || item.floors.dateKey >= effectiveLaneAdoptionDateKey; const floor = expectLaneFloor ? item.floors.laneFloor : item.floors.legacyFloor; if (item.sparkleBuild < floor) { const floorLabel = expectLaneFloor ? "lane floor" : "legacy floor"; errors.push( `appcast item '${item.title}' has sparkle:version ${item.sparkleBuild} below ${floorLabel} ${floor}.`, ); } } return errors; } function checkAppcastSparkleVersions() { const xml = readFileSync(appcastPath, "utf8"); const errors = collectAppcastSparkleVersionErrors(xml); if (errors.length > 0) { console.error("release-check: appcast sparkle version validation failed:"); for (const error of errors) { console.error(` - ${error}`); } process.exit(1); } } // Critical functions that channel extension plugins import from openclaw/plugin-sdk. // If any are missing from the compiled output, plugins crash at runtime (#27569). const requiredPluginSdkExports = [ "isDangerousNameMatchingEnabled", "createAccountListHelpers", "buildAgentMediaPayload", "createReplyPrefixOptions", "createTypingCallbacks", "logInboundDrop", "logTypingFailure", "buildPendingHistoryContextFromMap", "clearHistoryEntriesIfEnabled", "recordPendingHistoryEntryIfEnabled", "resolveControlCommandGate", "resolveDmGroupAccessWithLists", "resolveAllowlistProviderRuntimeGroupPolicy", "resolveDefaultGroupPolicy", "resolveChannelMediaMaxBytes", "warnMissingProviderGroupPolicyFallbackOnce", "emptyPluginConfigSchema", "onDiagnosticEvent", "normalizePluginHttpPath", "registerPluginHttpRoute", "DEFAULT_ACCOUNT_ID", "DEFAULT_GROUP_HISTORY_LIMIT", ]; async function collectDistPluginSdkExports(): Promise> { const pluginSdkDir = resolve("dist", "plugin-sdk"); let entries: string[]; try { entries = readdirSync(pluginSdkDir) .filter((entry) => entry.endsWith(".js")) .toSorted(); } catch { console.error("release-check: dist/plugin-sdk directory not found (build missing?)."); process.exit(1); return new Set(); } const exportedNames = new Set(); for (const entry of entries) { const content = readFileSync(join(pluginSdkDir, entry), "utf8"); for (const match of content.matchAll(/export\s*\{([^}]+)\}(?:\s*from\s*["'][^"']+["'])?/g)) { const names = match[1]?.split(",") ?? []; for (const name of names) { const parts = name.trim().split(/\s+as\s+/); const exportName = (parts[parts.length - 1] || "").trim(); if (exportName) { exportedNames.add(exportName); } } } for (const match of content.matchAll( /export\s+(?:const|function|class|let|var)\s+([A-Za-z0-9_$]+)/g, )) { const exportName = match[1]?.trim(); if (exportName) { exportedNames.add(exportName); } } } return exportedNames; } async function checkPluginSdkExports() { const exportedNames = await collectDistPluginSdkExports(); const missingExports = requiredPluginSdkExports.filter((name) => !exportedNames.has(name)); if (missingExports.length > 0) { console.error("release-check: missing critical plugin-sdk exports (#27569):"); for (const name of missingExports) { console.error(` - ${name}`); } process.exit(1); } } async function main() { checkAppcastSparkleVersions(); await checkPluginSdkExports(); checkBundledExtensionMetadata(); await writePackageDistInventory(process.cwd()); const results = runPackDry(); const files = results.flatMap((entry) => entry.files ?? []); const paths = new Set(files.map((file) => file.path)); const missing = requiredPathGroups .flatMap((group) => { if (Array.isArray(group)) { return group.some((path) => paths.has(path)) ? [] : [group.join(" or ")]; } return paths.has(group) ? [] : [group]; }) .toSorted((left, right) => left.localeCompare(right)); const forbidden = collectForbiddenPackPaths(paths); const forbiddenContent = collectForbiddenPackContentPaths(paths); const sizeErrors = collectNpmPackUnpackedSizeErrors(results); if ( missing.length > 0 || forbidden.length > 0 || forbiddenContent.length > 0 || sizeErrors.length > 0 ) { if (missing.length > 0) { console.error("release-check: missing files in npm pack:"); for (const path of missing) { console.error(` - ${path}`); } if ( missing.some( (path) => path === "dist/build-info.json" || path === "dist/control-ui/index.html" || path.startsWith("dist/"), ) ) { console.error( "release-check: build artifacts are missing. Run `pnpm build` before `pnpm release:check`.", ); } } if (forbidden.length > 0) { console.error("release-check: forbidden files in npm pack:"); for (const path of forbidden) { console.error(` - ${path}`); } } if (forbiddenContent.length > 0) { console.error("release-check: forbidden private QA markers in npm pack:"); for (const path of forbiddenContent) { console.error(` - ${path}`); } } if (sizeErrors.length > 0) { console.error("release-check: npm pack unpacked size budget exceeded:"); for (const error of sizeErrors) { console.error(` - ${error}`); } } process.exit(1); } runPackedBundledChannelEntrySmoke(); console.log("release-check: npm pack contents and bundled channel entrypoints look OK."); } if (import.meta.url === pathToFileURL(process.argv[1] ?? "").href) { void main().catch((error: unknown) => { console.error(error); process.exit(1); }); }