mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 13:10:43 +00:00
Add packed CLI smoke checks for release packaging (#70685)
* Add packed CLI smoke release checks * Address PR review feedback * Harden packed CLI smoke checks * Tighten release verifier parsing * Scan root dist module files in release verifier
This commit is contained in:
@@ -10,6 +10,7 @@ import {
|
||||
realpathSync,
|
||||
rmSync,
|
||||
} from "node:fs";
|
||||
import { builtinModules } from "node:module";
|
||||
import { tmpdir } from "node:os";
|
||||
import { isAbsolute, join, relative } from "node:path";
|
||||
import { pathToFileURL } from "node:url";
|
||||
@@ -20,9 +21,11 @@ import {
|
||||
collectBundledPluginRootRuntimeMirrorErrors,
|
||||
collectRootDistBundledRuntimeMirrors,
|
||||
collectRuntimeDependencySpecs,
|
||||
packageNameFromSpecifier,
|
||||
} from "./lib/bundled-plugin-root-runtime-mirrors.mjs";
|
||||
import { runInstalledWorkspaceBootstrapSmoke } from "./lib/workspace-bootstrap-smoke.mjs";
|
||||
import { parseReleaseVersion, resolveNpmCommandInvocation } from "./openclaw-npm-release-check.ts";
|
||||
import { createRequire } from "node:module";
|
||||
|
||||
type InstalledPackageJson = {
|
||||
version?: string;
|
||||
@@ -47,6 +50,13 @@ const LEGACY_CONTEXT_ENGINE_UNRESOLVED_RUNTIME_MARKER =
|
||||
const PUBLISHED_BUNDLED_RUNTIME_SIDECAR_PATHS = BUNDLED_RUNTIME_SIDECAR_PATHS.filter(
|
||||
(relativePath) => listBundledPluginPackArtifacts().includes(relativePath),
|
||||
);
|
||||
const NODE_BUILTIN_MODULES = new Set(builtinModules.map((name) => name.replace(/^node:/u, "")));
|
||||
const MAX_INSTALLED_ROOT_PACKAGE_JSON_BYTES = 1024 * 1024;
|
||||
const MAX_INSTALLED_ROOT_DIST_JS_BYTES = 2 * 1024 * 1024;
|
||||
const MAX_INSTALLED_ROOT_DIST_JS_FILES = 5000;
|
||||
const ROOT_DIST_JAVASCRIPT_MODULE_FILE_RE = /\.(?:c|m)?js$/u;
|
||||
const require = createRequire(import.meta.url);
|
||||
const acorn = require("acorn") as typeof import("acorn");
|
||||
|
||||
export type PublishedInstallScenario = {
|
||||
name: string;
|
||||
@@ -101,6 +111,7 @@ export function collectInstalledPackageErrors(params: {
|
||||
}
|
||||
|
||||
errors.push(...collectInstalledContextEngineRuntimeErrors(params.packageRoot));
|
||||
errors.push(...collectInstalledRootDependencyManifestErrors(params.packageRoot));
|
||||
errors.push(...collectInstalledMirroredRootDependencyManifestErrors(params.packageRoot));
|
||||
|
||||
return errors;
|
||||
@@ -131,7 +142,7 @@ function listDistJavaScriptFiles(packageRoot: string): string[] {
|
||||
pending.push(entryPath);
|
||||
continue;
|
||||
}
|
||||
if (entry.isFile() && entry.name.endsWith(".js")) {
|
||||
if (entry.isFile() && ROOT_DIST_JAVASCRIPT_MODULE_FILE_RE.test(entry.name)) {
|
||||
files.push(entryPath);
|
||||
}
|
||||
}
|
||||
@@ -154,6 +165,183 @@ export function collectInstalledContextEngineRuntimeErrors(packageRoot: string):
|
||||
return errors;
|
||||
}
|
||||
|
||||
function listInstalledRootDistJavaScriptFiles(packageRoot: string): string[] {
|
||||
const distDir = join(packageRoot, "dist");
|
||||
if (!existsSync(distDir)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const pending = [distDir];
|
||||
const files: string[] = [];
|
||||
while (pending.length > 0) {
|
||||
const currentDir = pending.pop();
|
||||
if (!currentDir) {
|
||||
continue;
|
||||
}
|
||||
for (const entry of readdirSync(currentDir, { withFileTypes: true })) {
|
||||
const entryPath = join(currentDir, entry.name);
|
||||
const relativePath = relative(distDir, entryPath).replaceAll("\\", "/");
|
||||
if (relativePath.startsWith("extensions/")) {
|
||||
continue;
|
||||
}
|
||||
if (entry.isDirectory()) {
|
||||
pending.push(entryPath);
|
||||
continue;
|
||||
}
|
||||
if (entry.isFile() && ROOT_DIST_JAVASCRIPT_MODULE_FILE_RE.test(entry.name)) {
|
||||
files.push(entryPath);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return files;
|
||||
}
|
||||
|
||||
type ParsedImportSpecifiersResult =
|
||||
| { ok: true; specifiers: Set<string> }
|
||||
| { ok: false; error: string };
|
||||
|
||||
function extractLiteralSpecifier(node: unknown): string | null {
|
||||
if (!node || typeof node !== "object") {
|
||||
return null;
|
||||
}
|
||||
const candidate = node as { type?: string; value?: unknown };
|
||||
if (candidate.type === "Literal" && typeof candidate.value === "string") {
|
||||
return candidate.value;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function extractJavaScriptImportSpecifiers(source: string): ParsedImportSpecifiersResult {
|
||||
const specifiers = new Set<string>();
|
||||
let program: unknown;
|
||||
try {
|
||||
program = acorn.parse(source, {
|
||||
allowHashBang: true,
|
||||
ecmaVersion: "latest",
|
||||
sourceType: "module",
|
||||
});
|
||||
} catch (error) {
|
||||
return { ok: false, error: formatErrorMessage(error) };
|
||||
}
|
||||
|
||||
const visited = new Set<unknown>();
|
||||
const pending: unknown[] = [program];
|
||||
while (pending.length > 0) {
|
||||
const current = pending.pop();
|
||||
if (!current || typeof current !== "object" || visited.has(current)) {
|
||||
continue;
|
||||
}
|
||||
visited.add(current);
|
||||
const node = current as Record<string, unknown>;
|
||||
const nodeType = typeof node.type === "string" ? node.type : null;
|
||||
|
||||
if (nodeType === "ImportDeclaration") {
|
||||
const specifier = extractLiteralSpecifier(node.source);
|
||||
if (specifier) {
|
||||
specifiers.add(specifier);
|
||||
}
|
||||
} else if (nodeType === "ExportAllDeclaration" || nodeType === "ExportNamedDeclaration") {
|
||||
const specifier = extractLiteralSpecifier(node.source);
|
||||
if (specifier) {
|
||||
specifiers.add(specifier);
|
||||
}
|
||||
} else if (nodeType === "ImportExpression") {
|
||||
const specifier = extractLiteralSpecifier(node.source);
|
||||
if (specifier) {
|
||||
specifiers.add(specifier);
|
||||
}
|
||||
} else if (nodeType === "CallExpression") {
|
||||
const callee = node.callee as { type?: string; name?: string } | undefined;
|
||||
const args = Array.isArray(node.arguments) ? node.arguments : [];
|
||||
if (callee?.type === "Identifier" && callee.name === "require" && args.length === 1) {
|
||||
const specifier = extractLiteralSpecifier(args[0]);
|
||||
if (specifier) {
|
||||
specifiers.add(specifier);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const value of Object.values(node)) {
|
||||
if (Array.isArray(value)) {
|
||||
pending.push(...value);
|
||||
} else if (value && typeof value === "object") {
|
||||
pending.push(value);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { ok: true, specifiers };
|
||||
}
|
||||
|
||||
export function collectInstalledRootDependencyManifestErrors(packageRoot: string): string[] {
|
||||
const packageJsonPath = join(packageRoot, "package.json");
|
||||
if (!existsSync(packageJsonPath)) {
|
||||
return ["installed package is missing package.json."];
|
||||
}
|
||||
const packageJsonStat = lstatSync(packageJsonPath);
|
||||
if (!packageJsonStat.isFile() || packageJsonStat.size > MAX_INSTALLED_ROOT_PACKAGE_JSON_BYTES) {
|
||||
return [
|
||||
`installed package.json is invalid or exceeds ${MAX_INSTALLED_ROOT_PACKAGE_JSON_BYTES} bytes.`,
|
||||
];
|
||||
}
|
||||
let rootPackageJson: InstalledPackageJson;
|
||||
try {
|
||||
rootPackageJson = JSON.parse(readFileSync(packageJsonPath, "utf8")) as InstalledPackageJson;
|
||||
} catch (error) {
|
||||
return [`installed package.json could not be parsed: ${formatErrorMessage(error)}.`];
|
||||
}
|
||||
const declaredRuntimeDeps = new Set([
|
||||
...Object.keys(rootPackageJson.dependencies ?? {}),
|
||||
...Object.keys(rootPackageJson.optionalDependencies ?? {}),
|
||||
]);
|
||||
const distFiles = listInstalledRootDistJavaScriptFiles(packageRoot);
|
||||
if (distFiles.length > MAX_INSTALLED_ROOT_DIST_JS_FILES) {
|
||||
return [
|
||||
`installed package root dist contains ${distFiles.length} JavaScript files, exceeding the ${MAX_INSTALLED_ROOT_DIST_JS_FILES} file scan limit.`,
|
||||
];
|
||||
}
|
||||
const missingImporters = new Map<string, Set<string>>();
|
||||
|
||||
for (const filePath of distFiles) {
|
||||
const fileStat = lstatSync(filePath);
|
||||
if (!fileStat.isFile() || fileStat.size > MAX_INSTALLED_ROOT_DIST_JS_BYTES) {
|
||||
const relativePath = relative(join(packageRoot, "dist"), filePath).replaceAll("\\", "/");
|
||||
return [
|
||||
`installed package root dist file '${relativePath}' is invalid or exceeds ${MAX_INSTALLED_ROOT_DIST_JS_BYTES} bytes.`,
|
||||
];
|
||||
}
|
||||
const source = readFileSync(filePath, "utf8");
|
||||
const relativePath = relative(join(packageRoot, "dist"), filePath).replaceAll("\\", "/");
|
||||
const parsedSpecifiers = extractJavaScriptImportSpecifiers(source);
|
||||
if (!parsedSpecifiers.ok) {
|
||||
return [
|
||||
`installed package root dist file '${relativePath}' could not be parsed for runtime dependency verification: ${parsedSpecifiers.error}.`,
|
||||
];
|
||||
}
|
||||
for (const specifier of parsedSpecifiers.specifiers) {
|
||||
const dependencyName = packageNameFromSpecifier(specifier);
|
||||
if (
|
||||
!dependencyName ||
|
||||
NODE_BUILTIN_MODULES.has(dependencyName) ||
|
||||
declaredRuntimeDeps.has(dependencyName)
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
const importers = missingImporters.get(dependencyName) ?? new Set<string>();
|
||||
importers.add(relativePath);
|
||||
missingImporters.set(dependencyName, importers);
|
||||
}
|
||||
}
|
||||
|
||||
return [...missingImporters.entries()]
|
||||
.map(([dependencyName, importers]) => {
|
||||
const importerList = [...importers].toSorted((left, right) => left.localeCompare(right));
|
||||
return `installed package root is missing declared runtime dependency '${dependencyName}' for dist importers: ${importerList.join(", ")}. Add it to package.json dependencies/optionalDependencies.`;
|
||||
})
|
||||
.toSorted((left, right) => left.localeCompare(right));
|
||||
}
|
||||
|
||||
export function resolveInstalledBinaryPath(prefixDir: string, platform = process.platform): string {
|
||||
return platform === "win32"
|
||||
? join(prefixDir, "openclaw.cmd")
|
||||
|
||||
@@ -11,7 +11,7 @@ import {
|
||||
writeFileSync,
|
||||
} from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join, resolve } from "node:path";
|
||||
import { dirname, join, resolve } from "node:path";
|
||||
import { pathToFileURL } from "node:url";
|
||||
import {
|
||||
PACKAGE_DIST_INVENTORY_RELATIVE_PATH,
|
||||
@@ -38,6 +38,7 @@ import {
|
||||
import { discoverBundledPluginRuntimeDeps } from "./postinstall-bundled-plugins.mjs";
|
||||
import { listStaticExtensionAssetOutputs } from "./runtime-postbuild.mjs";
|
||||
import { sparkleBuildFloorsFromShortVersion, type SparkleBuildFloors } from "./sparkle-build.ts";
|
||||
import { buildCmdExeCommandLine } from "./windows-cmd-helpers.mjs";
|
||||
|
||||
export { collectBundledExtensionManifestErrors } from "./lib/bundled-extension-manifest.ts";
|
||||
export {
|
||||
@@ -92,6 +93,13 @@ const forbiddenPrivateQaContentScanPrefixes = ["dist/"] as const;
|
||||
const appcastPath = resolve("appcast.xml");
|
||||
const laneBuildMin = 1_000_000_000;
|
||||
const laneFloorAdoptionDateKey = 20260227;
|
||||
const SAFE_UNIX_SMOKE_PATH = "/usr/bin:/bin";
|
||||
export const PACKED_CLI_SMOKE_COMMANDS = [
|
||||
["--help"],
|
||||
["status", "--json", "--timeout", "1"],
|
||||
["config", "schema"],
|
||||
["models", "list", "--provider", "amazon-bedrock"],
|
||||
] as const;
|
||||
|
||||
function collectBundledExtensions(): BundledExtension[] {
|
||||
const extensionsDir = resolve("extensions");
|
||||
@@ -209,6 +217,12 @@ function resolveGlobalRoot(prefixDir: string, cwd: string): string {
|
||||
}).trim();
|
||||
}
|
||||
|
||||
function resolveInstalledBinaryPath(prefixDir: string): string {
|
||||
return process.platform === "win32"
|
||||
? join(prefixDir, "openclaw.cmd")
|
||||
: join(prefixDir, "bin", "openclaw");
|
||||
}
|
||||
|
||||
export function createPackedBundledPluginPostinstallEnv(
|
||||
env: NodeJS.ProcessEnv = process.env,
|
||||
): NodeJS.ProcessEnv {
|
||||
@@ -218,6 +232,52 @@ export function createPackedBundledPluginPostinstallEnv(
|
||||
};
|
||||
}
|
||||
|
||||
export function createPackedCliSmokeEnv(
|
||||
env: NodeJS.ProcessEnv,
|
||||
overrides: NodeJS.ProcessEnv = {},
|
||||
): NodeJS.ProcessEnv {
|
||||
const allowlistedEnvEntries = [
|
||||
"HOME",
|
||||
"TMPDIR",
|
||||
"TMP",
|
||||
"TEMP",
|
||||
"SystemRoot",
|
||||
"ComSpec",
|
||||
"PATHEXT",
|
||||
"WINDIR",
|
||||
] as const;
|
||||
const windowsRoot = env.SystemRoot ?? env.WINDIR ?? "C:\\Windows";
|
||||
const nodeBinDir = dirname(process.execPath);
|
||||
const trustedCmdPath = join(windowsRoot, "System32", "cmd.exe");
|
||||
const safePath =
|
||||
process.platform === "win32"
|
||||
? `${nodeBinDir};${windowsRoot}\\System32;${windowsRoot}`
|
||||
: `${nodeBinDir}:${SAFE_UNIX_SMOKE_PATH}`;
|
||||
const homeDir = overrides.HOME ?? env.HOME ?? overrides.USERPROFILE ?? env.USERPROFILE ?? "";
|
||||
|
||||
return {
|
||||
...Object.fromEntries(
|
||||
allowlistedEnvEntries.flatMap((key) => {
|
||||
const value = env[key];
|
||||
return typeof value === "string" && value.length > 0 ? [[key, value]] : [];
|
||||
}),
|
||||
),
|
||||
PATH: safePath,
|
||||
HOME: homeDir,
|
||||
USERPROFILE: homeDir,
|
||||
ComSpec: trustedCmdPath,
|
||||
APPDATA: homeDir ? join(homeDir, "AppData", "Roaming") : undefined,
|
||||
LOCALAPPDATA: homeDir ? join(homeDir, "AppData", "Local") : undefined,
|
||||
AWS_EC2_METADATA_DISABLED: "true",
|
||||
AWS_SHARED_CREDENTIALS_FILE: homeDir ? join(homeDir, ".aws", "credentials") : undefined,
|
||||
AWS_CONFIG_FILE: homeDir ? join(homeDir, ".aws", "config") : undefined,
|
||||
OPENCLAW_DISABLE_BUNDLED_ENTRY_SOURCE_FALLBACK: "1",
|
||||
OPENCLAW_NO_ONBOARD: "1",
|
||||
OPENCLAW_SUPPRESS_NOTES: "1",
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function runPackedBundledPluginPostinstall(packageRoot: string): void {
|
||||
execFileSync(process.execPath, [join(packageRoot, "scripts/postinstall-bundled-plugins.mjs")], {
|
||||
cwd: packageRoot,
|
||||
@@ -378,6 +438,41 @@ function runPackedBundledPluginActivationSmoke(packageRoot: string, tmpRoot: str
|
||||
}
|
||||
}
|
||||
|
||||
function runPackedCliSmoke(params: {
|
||||
prefixDir: string;
|
||||
cwd: string;
|
||||
homeDir: string;
|
||||
stateDir: string;
|
||||
}): void {
|
||||
const binaryPath = resolveInstalledBinaryPath(params.prefixDir);
|
||||
const env = createPackedCliSmokeEnv(process.env, {
|
||||
HOME: params.homeDir,
|
||||
OPENCLAW_STATE_DIR: params.stateDir,
|
||||
OPENAI_API_KEY: "sk-openclaw-release-check",
|
||||
});
|
||||
const windowsRoot = env.SystemRoot ?? env.WINDIR ?? "C:\\Windows";
|
||||
const trustedCmdPath = join(windowsRoot, "System32", "cmd.exe");
|
||||
|
||||
for (const args of PACKED_CLI_SMOKE_COMMANDS) {
|
||||
if (process.platform === "win32") {
|
||||
execFileSync(trustedCmdPath, ["/d", "/s", "/c", buildCmdExeCommandLine(binaryPath, [...args])], {
|
||||
cwd: params.cwd,
|
||||
stdio: "inherit",
|
||||
env,
|
||||
shell: false,
|
||||
windowsVerbatimArguments: true,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
execFileSync(binaryPath, [...args], {
|
||||
cwd: params.cwd,
|
||||
stdio: "inherit",
|
||||
env,
|
||||
shell: false,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function runPackedBundledChannelEntrySmoke(): void {
|
||||
const tmpRoot = mkdtempSync(join(tmpdir(), "openclaw-release-pack-smoke-"));
|
||||
try {
|
||||
@@ -390,6 +485,15 @@ function runPackedBundledChannelEntrySmoke(): void {
|
||||
installPackedTarball(prefixDir, tarballPath, tmpRoot);
|
||||
|
||||
const packageRoot = join(resolveGlobalRoot(prefixDir, tmpRoot), "openclaw");
|
||||
const homeDir = join(tmpRoot, "home");
|
||||
const stateDir = join(tmpRoot, "state");
|
||||
mkdirSync(homeDir, { recursive: true });
|
||||
runPackedCliSmoke({
|
||||
prefixDir,
|
||||
cwd: packageRoot,
|
||||
homeDir,
|
||||
stateDir,
|
||||
});
|
||||
runPackedBundledPluginPostinstall(packageRoot);
|
||||
runPackedBundledPluginActivationSmoke(packageRoot, tmpRoot);
|
||||
execFileSync(
|
||||
@@ -408,9 +512,6 @@ function runPackedBundledChannelEntrySmoke(): void {
|
||||
},
|
||||
);
|
||||
|
||||
const homeDir = join(tmpRoot, "home");
|
||||
const stateDir = join(tmpRoot, "state");
|
||||
mkdirSync(homeDir, { recursive: true });
|
||||
execFileSync(
|
||||
process.execPath,
|
||||
[join(packageRoot, "openclaw.mjs"), "completion", "--write-state"],
|
||||
|
||||
Reference in New Issue
Block a user