mirror of
https://github.com/openclaw/openclaw.git
synced 2026-04-12 07:05:16 +07:00
Packaging: include optional bundled plugins in npm packs
This commit is contained in:
9
.github/workflows/ci.yml
vendored
9
.github/workflows/ci.yml
vendored
@@ -212,11 +212,8 @@ jobs:
|
||||
install-bun: "false"
|
||||
use-sticky-disk: "false"
|
||||
|
||||
- name: Download dist artifact
|
||||
uses: actions/download-artifact@v8
|
||||
with:
|
||||
name: dist-build
|
||||
path: dist/
|
||||
- name: Build npm pack dist
|
||||
run: pnpm build:npm-pack
|
||||
|
||||
- name: Check release contents
|
||||
run: pnpm release:check
|
||||
@@ -260,7 +257,7 @@ jobs:
|
||||
node_version: "22.x"
|
||||
cache_key_suffix: "node22"
|
||||
command: |
|
||||
pnpm build
|
||||
pnpm build:npm-pack
|
||||
node openclaw.mjs --help
|
||||
node openclaw.mjs status --json --timeout 1
|
||||
pnpm test:build:singleton
|
||||
|
||||
2
.github/workflows/macos-release.yml
vendored
2
.github/workflows/macos-release.yml
vendored
@@ -78,7 +78,7 @@ jobs:
|
||||
run: pnpm check
|
||||
|
||||
- name: Build
|
||||
run: pnpm build
|
||||
run: pnpm build:npm-pack
|
||||
|
||||
- name: Build Control UI
|
||||
run: node scripts/ui.js build
|
||||
|
||||
2
.github/workflows/openclaw-npm-release.yml
vendored
2
.github/workflows/openclaw-npm-release.yml
vendored
@@ -81,7 +81,7 @@ jobs:
|
||||
run: pnpm check
|
||||
|
||||
- name: Build
|
||||
run: pnpm build
|
||||
run: pnpm build:npm-pack
|
||||
|
||||
- name: Verify release contents
|
||||
run: pnpm release:check
|
||||
|
||||
@@ -588,6 +588,7 @@
|
||||
"android:test:third-party": "cd apps/android && ./gradlew :app:testThirdPartyDebugUnitTest",
|
||||
"build": "pnpm canvas:a2ui:bundle && node scripts/tsdown-build.mjs && node scripts/runtime-postbuild.mjs && node scripts/build-stamp.mjs && pnpm build:plugin-sdk:dts && node --import tsx scripts/write-plugin-sdk-entry-dts.ts && node --import tsx scripts/canvas-a2ui-copy.ts && node --import tsx scripts/copy-hook-metadata.ts && node --import tsx scripts/copy-export-html-templates.ts && node --import tsx scripts/write-build-info.ts && node --import tsx scripts/write-cli-startup-metadata.ts && node --import tsx scripts/write-cli-compat.ts",
|
||||
"build:docker": "node scripts/tsdown-build.mjs && node scripts/runtime-postbuild.mjs && node scripts/build-stamp.mjs && node --import tsx scripts/canvas-a2ui-copy.ts && node --import tsx scripts/copy-hook-metadata.ts && node --import tsx scripts/copy-export-html-templates.ts && node --import tsx scripts/write-build-info.ts && node --import tsx scripts/write-cli-startup-metadata.ts && node --import tsx scripts/write-cli-compat.ts",
|
||||
"build:npm-pack": "node scripts/build-npm-pack.mjs",
|
||||
"build:plugin-sdk:dts": "tsc -p tsconfig.plugin-sdk.dts.json",
|
||||
"build:strict-smoke": "pnpm canvas:a2ui:bundle && node scripts/tsdown-build.mjs && node scripts/runtime-postbuild.mjs && node scripts/build-stamp.mjs && pnpm build:plugin-sdk:dts",
|
||||
"canvas:a2ui:bundle": "bash scripts/bundle-a2ui.sh",
|
||||
@@ -676,7 +677,7 @@
|
||||
"plugin-sdk:check-exports": "node scripts/sync-plugin-sdk-exports.mjs --check",
|
||||
"plugin-sdk:sync-exports": "node scripts/sync-plugin-sdk-exports.mjs",
|
||||
"plugins:sync": "node --import tsx scripts/sync-plugin-versions.ts",
|
||||
"prepack": "pnpm build && pnpm ui:build",
|
||||
"prepack": "pnpm build:npm-pack && pnpm ui:build",
|
||||
"prepare": "command -v git >/dev/null 2>&1 && git rev-parse --is-inside-work-tree >/dev/null 2>&1 && git config core.hooksPath git-hooks || exit 0",
|
||||
"protocol:check": "pnpm protocol:gen && pnpm protocol:gen:swift && git diff --exit-code -- dist/protocol.schema.json apps/macos/Sources/OpenClawProtocol/GatewayModels.swift apps/shared/OpenClawKit/Sources/OpenClawProtocol/GatewayModels.swift",
|
||||
"protocol:gen": "node --import tsx scripts/protocol-gen.ts",
|
||||
|
||||
27
scripts/build-npm-pack.mjs
Normal file
27
scripts/build-npm-pack.mjs
Normal file
@@ -0,0 +1,27 @@
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { pathToFileURL } from "node:url";
|
||||
|
||||
const PNPM_COMMAND = process.platform === "win32" ? "pnpm.cmd" : "pnpm";
|
||||
|
||||
function main() {
|
||||
execFileSync(PNPM_COMMAND, ["build"], {
|
||||
stdio: "inherit",
|
||||
env: {
|
||||
...process.env,
|
||||
OPENCLAW_INCLUDE_OPTIONAL_BUNDLED: "1",
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
if (import.meta.url === pathToFileURL(process.argv[1] ?? "").href) {
|
||||
try {
|
||||
main();
|
||||
} catch (error) {
|
||||
const status =
|
||||
typeof error === "object" && error !== null && "status" in error ? error.status : undefined;
|
||||
if (typeof status === "number") {
|
||||
process.exit(status);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
@@ -4,3 +4,8 @@ export const OPTIONAL_BUNDLED_BUILD_ENV: "OPENCLAW_INCLUDE_OPTIONAL_BUNDLED";
|
||||
export function isOptionalBundledCluster(cluster: string): boolean;
|
||||
export function shouldIncludeOptionalBundledClusters(env?: NodeJS.ProcessEnv): boolean;
|
||||
export function shouldBuildBundledCluster(cluster: string, env?: NodeJS.ProcessEnv): boolean;
|
||||
export function resolveOptionalBundledClusterRequiredPackPaths(repoRoot?: string): string[];
|
||||
export function collectMissingOptionalBundledClusterPackPaths(
|
||||
paths: Iterable<string>,
|
||||
repoRoot?: string,
|
||||
): string[];
|
||||
|
||||
5
scripts/lib/optional-bundled-clusters.d.ts
vendored
5
scripts/lib/optional-bundled-clusters.d.ts
vendored
@@ -4,3 +4,8 @@ export const OPTIONAL_BUNDLED_BUILD_ENV: "OPENCLAW_INCLUDE_OPTIONAL_BUNDLED";
|
||||
export function isOptionalBundledCluster(cluster: string): boolean;
|
||||
export function shouldIncludeOptionalBundledClusters(env?: NodeJS.ProcessEnv): boolean;
|
||||
export function shouldBuildBundledCluster(cluster: string, env?: NodeJS.ProcessEnv): boolean;
|
||||
export function resolveOptionalBundledClusterRequiredPackPaths(repoRoot?: string): string[];
|
||||
export function collectMissingOptionalBundledClusterPackPaths(
|
||||
paths: Iterable<string>,
|
||||
repoRoot?: string,
|
||||
): string[];
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
|
||||
export const optionalBundledClusters = [
|
||||
"acpx",
|
||||
"diagnostics-otel",
|
||||
@@ -29,3 +32,21 @@ export function shouldIncludeOptionalBundledClusters(env = process.env) {
|
||||
export function shouldBuildBundledCluster(cluster, env = process.env) {
|
||||
return shouldIncludeOptionalBundledClusters(env) || !isOptionalBundledCluster(cluster);
|
||||
}
|
||||
|
||||
export function resolveOptionalBundledClusterRequiredPackPaths(repoRoot = process.cwd()) {
|
||||
return optionalBundledClusters
|
||||
.filter((cluster) =>
|
||||
fs.existsSync(path.join(repoRoot, "extensions", cluster, "openclaw.plugin.json")),
|
||||
)
|
||||
.flatMap((cluster) => [
|
||||
`dist/extensions/${cluster}/openclaw.plugin.json`,
|
||||
`dist/extensions/${cluster}/package.json`,
|
||||
]);
|
||||
}
|
||||
|
||||
export function collectMissingOptionalBundledClusterPackPaths(paths, repoRoot = process.cwd()) {
|
||||
const presentPaths = new Set(paths);
|
||||
return resolveOptionalBundledClusterRequiredPackPaths(repoRoot).filter(
|
||||
(path) => !presentPaths.has(path),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { pathToFileURL } from "node:url";
|
||||
import { resolveOptionalBundledClusterRequiredPackPaths } from "./lib/optional-bundled-clusters.mjs";
|
||||
|
||||
type PackageJson = {
|
||||
name?: string;
|
||||
@@ -39,7 +40,6 @@ const BETA_VERSION_REGEX =
|
||||
const CORRECTION_TAG_REGEX = /^(?<base>\d{4}\.[1-9]\d?\.[1-9]\d?)-(?<correction>[1-9]\d*)$/;
|
||||
const EXPECTED_REPOSITORY_URL = "https://github.com/openclaw/openclaw";
|
||||
const MAX_CALVER_DISTANCE_DAYS = 2;
|
||||
const REQUIRED_PACKED_PATHS = ["dist/control-ui/index.html"];
|
||||
const NPM_COMMAND = process.platform === "win32" ? "npm.cmd" : "npm";
|
||||
|
||||
function normalizeRepoUrl(value: unknown): string {
|
||||
@@ -289,13 +289,6 @@ function loadPackageJson(): PackageJson {
|
||||
}
|
||||
|
||||
function runNpmCommand(args: string[]): string {
|
||||
const npmExecPath = process.env.npm_execpath;
|
||||
if (typeof npmExecPath === "string" && npmExecPath.length > 0) {
|
||||
return execFileSync(process.execPath, [npmExecPath, ...args], {
|
||||
encoding: "utf8",
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
});
|
||||
}
|
||||
return execFileSync(NPM_COMMAND, args, {
|
||||
encoding: "utf8",
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
@@ -312,15 +305,15 @@ type NpmPackResult = {
|
||||
};
|
||||
|
||||
type ExecFailure = Error & {
|
||||
stderr?: string | Buffer;
|
||||
stdout?: string | Buffer;
|
||||
stderr?: string | Uint8Array;
|
||||
stdout?: string | Uint8Array;
|
||||
};
|
||||
|
||||
function toTrimmedUtf8(value: string | Buffer | undefined): string {
|
||||
function toTrimmedUtf8(value: string | Uint8Array | undefined): string {
|
||||
if (typeof value === "string") {
|
||||
return value.trim();
|
||||
}
|
||||
if (Buffer.isBuffer(value)) {
|
||||
if (value instanceof Uint8Array) {
|
||||
return value.toString("utf8").trim();
|
||||
}
|
||||
return "";
|
||||
@@ -343,6 +336,41 @@ function describeExecFailure(error: unknown): string {
|
||||
return details.join(" | ");
|
||||
}
|
||||
|
||||
function parseTrailingJson(text: string): unknown {
|
||||
const candidates: number[] = [];
|
||||
for (let index = text.indexOf("["); index !== -1; index = text.indexOf("[", index + 1)) {
|
||||
candidates.push(index);
|
||||
}
|
||||
for (let i = candidates.length - 1; i >= 0; i -= 1) {
|
||||
const start = candidates[i];
|
||||
try {
|
||||
return JSON.parse(text.slice(start));
|
||||
} catch {
|
||||
// Keep scanning earlier `[` boundaries until we find the actual payload.
|
||||
}
|
||||
}
|
||||
throw new Error("no trailing JSON payload found");
|
||||
}
|
||||
|
||||
export function collectPackedTarballPathErrors(paths: Iterable<string>): string[] {
|
||||
const packedPaths = new Set(paths);
|
||||
const errors: string[] = [];
|
||||
const requiredPackedPaths = [
|
||||
"dist/control-ui/index.html",
|
||||
...resolveOptionalBundledClusterRequiredPackPaths(),
|
||||
];
|
||||
|
||||
for (const requiredPath of requiredPackedPaths) {
|
||||
if (!packedPaths.has(requiredPath)) {
|
||||
errors.push(
|
||||
`npm package is missing required path "${requiredPath}". Build the package with \`pnpm build:npm-pack\` and include UI assets before publish.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return errors;
|
||||
}
|
||||
|
||||
function collectPackedTarballErrors(): string[] {
|
||||
const errors: string[] = [];
|
||||
let stdout = "";
|
||||
@@ -358,7 +386,7 @@ function collectPackedTarballErrors(): string[] {
|
||||
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(stdout);
|
||||
parsed = parseTrailingJson(stdout);
|
||||
} catch {
|
||||
errors.push("Failed to parse JSON output from `npm pack --json --dry-run`.");
|
||||
return errors;
|
||||
@@ -376,16 +404,7 @@ function collectPackedTarballErrors(): string[] {
|
||||
.map((entry) => entry.path)
|
||||
.filter((path): path is string => typeof path === "string" && path.length > 0),
|
||||
);
|
||||
|
||||
for (const requiredPath of REQUIRED_PACKED_PATHS) {
|
||||
if (!packedPaths.has(requiredPath)) {
|
||||
errors.push(
|
||||
`npm package is missing required path "${requiredPath}". Ensure UI assets are built and included before publish.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return errors;
|
||||
return collectPackedTarballPathErrors(packedPaths);
|
||||
}
|
||||
|
||||
function main(): number {
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
type BundledExtension,
|
||||
type ExtensionPackageJson as PackageJson,
|
||||
} from "./lib/bundled-extension-manifest.ts";
|
||||
import { collectMissingOptionalBundledClusterPackPaths } from "./lib/optional-bundled-clusters.mjs";
|
||||
import { listPluginSdkDistArtifacts } from "./lib/plugin-sdk-entries.mjs";
|
||||
import { sparkleBuildFloorsFromShortVersion, type SparkleBuildFloors } from "./sparkle-build.ts";
|
||||
|
||||
@@ -27,13 +28,18 @@ const requiredPathGroups = [
|
||||
];
|
||||
const forbiddenPrefixes = ["dist-runtime/", "dist/OpenClaw.app/"];
|
||||
// 2026.3.12 ballooned to ~213.6 MiB unpacked and correlated with low-memory
|
||||
// startup/doctor OOM reports. Keep enough headroom for the current pack while
|
||||
// failing fast if duplicate/shim content sneaks back into the release artifact.
|
||||
const npmPackUnpackedSizeBudgetBytes = 160 * 1024 * 1024;
|
||||
// startup/doctor OOM reports. The npm release now intentionally includes the
|
||||
// optional bundled plugin set, so keep headroom above the current ~167.7 MiB
|
||||
// pack while still failing fast if duplicate/shim content sneaks back in.
|
||||
const npmPackUnpackedSizeBudgetBytes = 180 * 1024 * 1024;
|
||||
const appcastPath = resolve("appcast.xml");
|
||||
const laneBuildMin = 1_000_000_000;
|
||||
const laneFloorAdoptionDateKey = 20260227;
|
||||
|
||||
export function collectMissingBundledPluginPackPaths(paths: Iterable<string>): string[] {
|
||||
return collectMissingOptionalBundledClusterPackPaths(paths);
|
||||
}
|
||||
|
||||
function collectBundledExtensions(): BundledExtension[] {
|
||||
const extensionsDir = resolve("extensions");
|
||||
const entries = readdirSync(extensionsDir, { withFileTypes: true }).filter((entry) =>
|
||||
@@ -295,6 +301,7 @@ async function main() {
|
||||
const results = runPackDry();
|
||||
const files = results.flatMap((entry) => entry.files ?? []);
|
||||
const paths = new Set(files.map((file) => file.path));
|
||||
const missingBundledPluginPaths = collectMissingBundledPluginPackPaths(paths);
|
||||
|
||||
const missing = requiredPathGroups
|
||||
.flatMap((group) => {
|
||||
@@ -303,6 +310,7 @@ async function main() {
|
||||
}
|
||||
return paths.has(group) ? [] : [group];
|
||||
})
|
||||
.concat(missingBundledPluginPaths)
|
||||
.toSorted();
|
||||
const forbidden = collectForbiddenPackPaths(paths);
|
||||
const sizeErrors = collectPackUnpackedSizeErrors(results);
|
||||
@@ -313,6 +321,11 @@ async function main() {
|
||||
for (const path of missing) {
|
||||
console.error(` - ${path}`);
|
||||
}
|
||||
if (missingBundledPluginPaths.length > 0) {
|
||||
console.error(
|
||||
"release-check: rebuild pack artifacts with `pnpm build:npm-pack` so optional bundled plugins are included.",
|
||||
);
|
||||
}
|
||||
}
|
||||
if (forbidden.length > 0) {
|
||||
console.error("release-check: forbidden files in npm pack:");
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
collectPackedTarballPathErrors,
|
||||
collectReleasePackageMetadataErrors,
|
||||
collectReleaseTagErrors,
|
||||
parseReleaseTagVersion,
|
||||
@@ -132,3 +133,11 @@ describe("collectReleasePackageMetadataErrors", () => {
|
||||
).toContain('package.json peerDependenciesMeta["node-llama-cpp"].optional must be true.');
|
||||
});
|
||||
});
|
||||
|
||||
describe("collectPackedTarballPathErrors", () => {
|
||||
it("requires optional bundled plugin paths in the npm tarball", () => {
|
||||
expect(collectPackedTarballPathErrors(["dist/control-ui/index.html"])).toContain(
|
||||
'npm package is missing required path "dist/extensions/acpx/openclaw.plugin.json". Build the package with `pnpm build:npm-pack` and include UI assets before publish.',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -3,6 +3,7 @@ import {
|
||||
collectAppcastSparkleVersionErrors,
|
||||
collectBundledExtensionManifestErrors,
|
||||
collectForbiddenPackPaths,
|
||||
collectMissingBundledPluginPackPaths,
|
||||
collectPackUnpackedSizeErrors,
|
||||
} from "../scripts/release-check.ts";
|
||||
|
||||
@@ -115,6 +116,17 @@ describe("collectForbiddenPackPaths", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("collectMissingBundledPluginPackPaths", () => {
|
||||
it("requires optional bundled plugin metadata in npm packs", () => {
|
||||
expect(
|
||||
collectMissingBundledPluginPackPaths([
|
||||
"dist/extensions/discord/openclaw.plugin.json",
|
||||
"dist/extensions/discord/package.json",
|
||||
]),
|
||||
).toContain("dist/extensions/acpx/openclaw.plugin.json");
|
||||
});
|
||||
});
|
||||
|
||||
describe("collectPackUnpackedSizeErrors", () => {
|
||||
it("accepts pack results within the unpacked size budget", () => {
|
||||
expect(
|
||||
@@ -126,7 +138,7 @@ describe("collectPackUnpackedSizeErrors", () => {
|
||||
expect(
|
||||
collectPackUnpackedSizeErrors([makePackResult("openclaw-2026.3.12.tgz", 224_002_564)]),
|
||||
).toEqual([
|
||||
"openclaw-2026.3.12.tgz unpackedSize 224002564 bytes (213.6 MiB) exceeds budget 167772160 bytes (160.0 MiB). Investigate duplicate channel shims, copied extension trees, or other accidental pack bloat before release.",
|
||||
"openclaw-2026.3.12.tgz unpackedSize 224002564 bytes (213.6 MiB) exceeds budget 188743680 bytes (180.0 MiB). Investigate duplicate channel shims, copied extension trees, or other accidental pack bloat before release.",
|
||||
]);
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user