#!/usr/bin/env -S node --import tsx import { execSync } from "node:child_process"; import { readdirSync, readFileSync } from "node:fs"; import { join, resolve } from "node:path"; import { pathToFileURL } from "node:url"; import { sparkleBuildFloorsFromShortVersion, type SparkleBuildFloors } from "./sparkle-build.ts"; type PackFile = { path: string }; type PackResult = { files?: PackFile[] }; const requiredPathGroups = [ ["dist/index.js", "dist/index.mjs"], ["dist/entry.js", "dist/entry.mjs"], "dist/plugin-sdk/index.js", "dist/plugin-sdk/index.d.ts", "dist/build-info.json", ]; const forbiddenPrefixes = ["dist/OpenClaw.app/"]; const appcastPath = resolve("appcast.xml"); const laneBuildMin = 1_000_000_000; const laneFloorAdoptionDateKey = 20260227; type PackageJson = { name?: string; version?: string; }; function normalizePluginSyncVersion(version: string): string { const normalized = version.trim().replace(/^v/, ""); const base = /^([0-9]+\.[0-9]+\.[0-9]+)/.exec(normalized)?.[1]; if (base) { return base; } return normalized.replace(/[-+].*$/, ""); } 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 checkPluginVersions() { const rootPackagePath = resolve("package.json"); const rootPackage = JSON.parse(readFileSync(rootPackagePath, "utf8")) as PackageJson; const targetVersion = rootPackage.version; const targetBaseVersion = targetVersion ? normalizePluginSyncVersion(targetVersion) : null; if (!targetVersion || !targetBaseVersion) { console.error("release-check: root package.json missing version."); process.exit(1); } const extensionsDir = resolve("extensions"); const entries = readdirSync(extensionsDir, { withFileTypes: true }).filter((entry) => entry.isDirectory(), ); const mismatches: string[] = []; for (const entry of entries) { const packagePath = join(extensionsDir, entry.name, "package.json"); let pkg: PackageJson; try { pkg = JSON.parse(readFileSync(packagePath, "utf8")) as PackageJson; } catch { continue; } if (!pkg.name || !pkg.version) { continue; } if (normalizePluginSyncVersion(pkg.version) !== targetBaseVersion) { mismatches.push(`${pkg.name} (${pkg.version})`); } } if (mismatches.length > 0) { console.error( `release-check: plugin versions must match release base ${targetBaseVersion} (root ${targetVersion}):`, ); for (const item of mismatches) { console.error(` - ${item}`); } console.error("release-check: run `pnpm plugins:sync` to align plugin versions."); process.exit(1); } } 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", "normalizePluginHttpPath", "registerPluginHttpRoute", "DEFAULT_ACCOUNT_ID", "DEFAULT_GROUP_HISTORY_LIMIT", ]; function checkPluginSdkExports() { const distPath = resolve("dist", "plugin-sdk", "index.js"); let content: string; try { content = readFileSync(distPath, "utf8"); } catch { console.error("release-check: dist/plugin-sdk/index.js not found (build missing?)."); process.exit(1); return; } const exportMatch = content.match(/export\s*\{([^}]+)\}\s*;?\s*$/); if (!exportMatch) { console.error("release-check: could not find export statement in dist/plugin-sdk/index.js."); process.exit(1); return; } const exportedNames = new Set( exportMatch[1].split(",").map((s) => { const parts = s.trim().split(/\s+as\s+/); return (parts[parts.length - 1] || "").trim(); }), ); 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); } } function main() { checkPluginVersions(); checkAppcastSparkleVersions(); checkPluginSdkExports(); 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(); const forbidden = [...paths].filter((path) => forbiddenPrefixes.some((prefix) => path.startsWith(prefix)), ); if (missing.length > 0 || forbidden.length > 0) { if (missing.length > 0) { console.error("release-check: missing files in npm pack:"); for (const path of missing) { console.error(` - ${path}`); } } if (forbidden.length > 0) { console.error("release-check: forbidden files in npm pack:"); for (const path of forbidden) { console.error(` - ${path}`); } } process.exit(1); } console.log("release-check: npm pack contents look OK."); } if (import.meta.url === pathToFileURL(process.argv[1] ?? "").href) { main(); }