fix(release): harden plugin package preflight

This commit is contained in:
Vincent Koc
2026-06-15 14:23:57 +08:00
parent bcb016a528
commit 5b18b7560e
11 changed files with 254 additions and 12 deletions
@@ -321,6 +321,7 @@ Upgrade with the beta channel.
Before tagging or publishing, run:
```bash
pnpm release:fast-pretag-check
pnpm check:architecture
pnpm build
pnpm ui:build
@@ -329,6 +330,21 @@ pnpm release:check
pnpm test:install:smoke
```
- Treat `pnpm release:fast-pretag-check` as a hard packaging gate. Every
publishable plugin must have a non-empty package-root `README.md`, build its
package-local runtime, and pass the npm and ClawHub release metadata checks
before a tag or publish workflow can start. Do not defer README, entrypoint,
or packed-artifact failures to postpublish verification.
- Before tagging, require green CI for the exact release-candidate SHA, not an
earlier branch SHA. Heal every related red CI, release-check, packaging, or
root-Dockerfile lane on the release branch, forward-port the fix to `main`,
and rerun the affected exact-SHA gates. Never waive a red Docker lane because
npm preflight passed.
- Root Dockerfile proof is mandatory before every beta and stable tag. Run the
release `install-smoke` group or equivalent root Dockerfile build for the
exact candidate SHA and require it to pass. The tag-triggered Docker Release
workflow is post-tag publishing, not the first valid proof that the root
Dockerfile can build.
- Before tagging, diff publishable plugin package manifests against the last
reachable stable/beta release tag. For every newly publishable package
(`openclaw.release.publishToNpm: true` or `publishToClawHub: true`) whose
@@ -644,9 +660,10 @@ node --import tsx scripts/openclaw-npm-postpublish-verify.ts <published-version>
off, live OpenAI off, and regression failure off. Let it run in parallel
with preflight and validation work.
10. Run the fast local beta preflight from the release branch before any npm
preflight or publish. Keep expensive Docker, Parallels, and published-package
install/update lanes for after the beta is live unless the operator asks to
run them before beta publication.
preflight or publish. Require exact-SHA CI and root Dockerfile install-smoke
to be green before tagging. Keep the remaining expensive Docker, Parallels,
and published-package install/update lanes for after the beta is live unless
the operator asks to run them before beta publication.
11. For beta releases, skip mac app build/sign/notarize unless beta scope or a
release blocker specifically requires it. For stable releases, include the
mac app, signing, notarization, and appcast path.
+27
View File
@@ -0,0 +1,27 @@
# @openclaw/llama-cpp-provider
Official llama.cpp embedding provider for OpenClaw.
This plugin runs local GGUF embedding models through `node-llama-cpp`.
## Install
```bash
openclaw plugins install @openclaw/llama-cpp-provider
```
Restart the Gateway after installing or updating the plugin. Use Node 24 for
native installs and updates.
## Configure
Set `agents.defaults.memorySearch.provider` to `local`. By default, the plugin
downloads and uses the EmbeddingGemma GGUF model. Configure
`agents.defaults.memorySearch.local.modelPath` to use another local path, Hugging
Face model URI, or HTTPS model URL.
## Package
- Plugin id: `llama-cpp`
- Package: `@openclaw/llama-cpp-provider`
- Minimum OpenClaw host: `2026.6.2`
+1 -5
View File
@@ -154,11 +154,7 @@ export function collectClawHubPublishablePluginPackages(
continue;
}
const errors = collectPublishablePluginPackageErrors({
extensionId,
packageDir,
packageJson,
});
const errors = collectPublishablePluginPackageErrors(candidate);
if (errors.length > 0) {
validationErrors.push(...errors.map((error) => `${extensionId}: ${error}`));
continue;
+6 -1
View File
@@ -444,7 +444,12 @@ export function resolveAugmentedPluginNpmPackageJson(params) {
openclaw: {
...plan.packageJson.openclaw,
runtimeExtensions: plan.runtimeExtensions,
...(plan.runtimeSetupEntry ? { runtimeSetupEntry: plan.runtimeSetupEntry } : {}),
...(plan.runtimeSetupEntry
? {
setupEntry: plan.runtimeSetupEntry,
runtimeSetupEntry: plan.runtimeSetupEntry,
}
: {}),
},
};
if (shouldBundleDependencies(params.bundleDependencies, plan.packageJson)) {
+13
View File
@@ -80,6 +80,7 @@ export type PublishablePluginPackageCandidate<
extensionId: string;
packageDir: string;
packageJson: TPackageJson;
readmeText?: string;
};
export const OPENCLAW_PLUGIN_NPM_REPOSITORY_URL = "https://github.com/openclaw/openclaw";
@@ -88,6 +89,14 @@ function readPluginPackageJson(path: string): unknown {
return JSON.parse(readFileSync(path, "utf8"));
}
function readOptionalTextFile(path: string): string | undefined {
try {
return readFileSync(path, "utf8");
} catch {
return undefined;
}
}
export function collectExtensionPackageJsonCandidates<
TPackageJson extends PluginPackageJson = PluginPackageJson,
>(rootDir = resolve(".")): PublishablePluginPackageCandidate<TPackageJson>[] {
@@ -106,6 +115,7 @@ export function collectExtensionPackageJsonCandidates<
extensionId: dir.name,
packageDir,
packageJson: readPluginPackageJson(packageJsonPath) as TPackageJson,
readmeText: readOptionalTextFile(join(absolutePackageDir, "README.md")),
});
} catch {
continue;
@@ -242,6 +252,9 @@ export function collectPublishablePluginPackageErrors(
if (packageJson.private === true) {
errors.push("package.json private must not be true.");
}
if (!candidate.readmeText?.trim()) {
errors.push("README.md must exist and contain package documentation.");
}
if (repositoryUrl !== OPENCLAW_PLUGIN_NPM_REPOSITORY_URL) {
errors.push(
`package.json repository.url must be "${OPENCLAW_PLUGIN_NPM_REPOSITORY_URL}" so npm provenance can validate GitHub trusted publishing; found "${repositoryUrl || "<missing>"}".`,
+126
View File
@@ -0,0 +1,126 @@
#!/usr/bin/env -S node --import tsx
import { execFileSync } from "node:child_process";
import { chmodSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join, resolve } from "node:path";
import { pathToFileURL } from "node:url";
import { collectClawHubPublishablePluginPackages } from "./lib/plugin-clawhub-release.ts";
import { collectPublishablePluginPackages } from "./lib/plugin-npm-release.ts";
const DEFAULT_CLAWHUB_CLI_PACKAGE = "clawhub@0.21.0";
type PluginReleasePretagPackTarget = {
packageDir: string;
packageName: string;
packClawHub: boolean;
packNpm: boolean;
};
export function collectPluginReleasePretagPackTargets(
rootDir = resolve("."),
): PluginReleasePretagPackTarget[] {
const targets = new Map<string, PluginReleasePretagPackTarget>();
for (const plugin of collectPublishablePluginPackages(rootDir)) {
targets.set(plugin.packageDir, {
packageDir: plugin.packageDir,
packageName: plugin.packageName,
packClawHub: false,
packNpm: true,
});
}
for (const plugin of collectClawHubPublishablePluginPackages(rootDir)) {
const existing = targets.get(plugin.packageDir);
targets.set(plugin.packageDir, {
packageDir: plugin.packageDir,
packageName: plugin.packageName,
packClawHub: true,
packNpm: existing?.packNpm ?? false,
});
}
return [...targets.values()].toSorted((left, right) =>
left.packageName.localeCompare(right.packageName),
);
}
function runCommand(
command: string,
args: string[],
params: { cwd: string; env?: NodeJS.ProcessEnv; quietStdout?: boolean },
) {
execFileSync(command, args, {
cwd: params.cwd,
env: params.env ?? process.env,
stdio: params.quietStdout ? ["inherit", "ignore", "inherit"] : "inherit",
});
}
export function runPluginReleasePretagPackCheck(rootDir = resolve(".")) {
const targets = collectPluginReleasePretagPackTargets(rootDir);
const tempRoot = mkdtempSync(join(tmpdir(), "openclaw-plugin-pretag-pack-"));
const wrapperDir = join(tempRoot, "bin");
mkdirSync(wrapperDir);
const clawHubWrapper = join(wrapperDir, "clawhub");
writeFileSync(
clawHubWrapper,
[
"#!/usr/bin/env bash",
"set -euo pipefail",
'exec npm exec --yes --package "${CLAWHUB_CLI_PACKAGE}" -- clawhub "$@"',
"",
].join("\n"),
);
chmodSync(clawHubWrapper, 0o755);
try {
runCommand(
process.execPath,
[
"scripts/check-plugin-npm-runtime-builds.mjs",
...targets.flatMap((target) => ["--package", target.packageDir]),
],
{
cwd: rootDir,
},
);
const packEnv = {
...process.env,
CLAWHUB_CLI_PACKAGE: process.env.CLAWHUB_CLI_PACKAGE?.trim() || DEFAULT_CLAWHUB_CLI_PACKAGE,
OPENCLAW_PLUGIN_NPM_RUNTIME_BUILD: "0",
PATH: `${wrapperDir}:${process.env.PATH ?? ""}`,
};
for (const [index, target] of targets.entries()) {
if (target.packNpm) {
console.log(`npm pack: ${target.packageName}`);
runCommand("bash", ["scripts/plugin-npm-publish.sh", "--pack-dry-run", target.packageDir], {
cwd: rootDir,
env: packEnv,
quietStdout: true,
});
}
if (target.packClawHub) {
const outputDir = join(tempRoot, `clawhub-${index}`);
console.log(`ClawHub pack: ${target.packageName}`);
runCommand("bash", ["scripts/plugin-clawhub-publish.sh", "--pack", target.packageDir], {
cwd: rootDir,
env: {
...packEnv,
OPENCLAW_CLAWHUB_PACK_OUTPUT_DIR: outputDir,
},
quietStdout: true,
});
}
}
} finally {
rmSync(tempRoot, { recursive: true, force: true });
}
console.log(`plugin-release-pretag-pack-check: packed ${targets.length} publishable plugins.`);
}
if (import.meta.url === pathToFileURL(process.argv[1] ?? "").href) {
runPluginReleasePretagPackCheck();
}
+1 -1
View File
@@ -12,7 +12,7 @@ pnpm plugins:sync:check
pnpm release:generated:check
pnpm release:plugins:npm:check -- --selection-mode all-publishable
pnpm release:plugins:clawhub:check -- --selection-mode all-publishable
node scripts/check-plugin-npm-runtime-builds.mjs --package extensions/diffs-language-pack
node --import tsx scripts/plugin-release-pretag-pack-check.ts
pnpm build
pnpm ui:build
pnpm release:openclaw:npm:check
+1
View File
@@ -1150,6 +1150,7 @@ function createTempPluginRepo(
join(repoDir, "extensions", currentExtensionId, "index.ts"),
`export const ${currentExtensionId.replaceAll(/[-.]/g, "_")} = 1;\n`,
);
writeFileSync(join(repoDir, "extensions", currentExtensionId, "README.md"), "# Demo plugin\n");
}
git(repoDir, ["init", "-b", "main"]);
+2 -1
View File
@@ -275,7 +275,7 @@ describe("plugin npm package manifest staging", () => {
},
openclaw: {
extensions: ["./index.ts"],
setupEntry: "./setup-entry.ts",
setupEntry: "./dist/setup-entry.js",
compat: {
pluginApi: ">=2026.4.30",
},
@@ -296,6 +296,7 @@ describe("plugin npm package manifest staging", () => {
);
expect(stagedPackageJson.openclaw.extensions).toEqual(["./index.ts"]);
expect(stagedPackageJson.openclaw.runtimeExtensions).toEqual(["./dist/index.js"]);
expect(stagedPackageJson.openclaw.setupEntry).toBe("./dist/setup-entry.js");
expect(stagedPackageJson.openclaw.runtimeSetupEntry).toBe("./dist/setup-entry.js");
expect(stagedPackageJson.bundledDependencies).toEqual([]);
expect(stagedPackageJson.bundleDependencies).toBeUndefined();
+43 -1
View File
@@ -1,5 +1,5 @@
// Plugin npm release tests validate plugin npm release artifacts.
import { mkdirSync, readFileSync } from "node:fs";
import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
import { join } from "node:path";
import { bundledPluginFile, bundledPluginRoot } from "openclaw/plugin-sdk/test-fixtures";
import { afterEach, describe, expect, it } from "vitest";
@@ -98,12 +98,19 @@ function externalPluginContract(version: string) {
};
}
function writePluginReadme(repoDir: string, extensionId: string): void {
const packageDir = join(repoDir, "extensions", extensionId);
mkdirSync(packageDir, { recursive: true });
writeFileSync(join(packageDir, "README.md"), `# ${extensionId}\n`);
}
describe("collectPublishablePluginPackageErrors", () => {
it("accepts a valid publishable plugin package candidate", () => {
expect(
collectPublishablePluginPackageErrors({
extensionId: "zalo",
packageDir: bundledPluginRoot("zalo"),
readmeText: "# Zalo\n",
packageJson: {
name: "@openclaw/zalo",
version: "2026.3.15",
@@ -131,6 +138,7 @@ describe("collectPublishablePluginPackageErrors", () => {
collectPublishablePluginPackageErrors({
extensionId: "broken",
packageDir: bundledPluginRoot("broken"),
readmeText: "# Broken\n",
packageJson: {
name: "broken",
version: "latest",
@@ -162,6 +170,7 @@ describe("collectPublishablePluginPackageErrors", () => {
collectPublishablePluginPackageErrors({
extensionId: "twitch",
packageDir: bundledPluginRoot("twitch"),
readmeText: "# Twitch\n",
packageJson: {
name: "@openclaw/twitch",
version: "2026.5.1-beta.1",
@@ -187,6 +196,7 @@ describe("collectPublishablePluginPackageErrors", () => {
collectPublishablePluginPackageErrors({
extensionId: "voice-call",
packageDir: bundledPluginRoot("voice-call"),
readmeText: "# Voice call\n",
packageJson: {
name: "@openclaw/voice-call",
version: "2026.5.1-beta.1",
@@ -211,6 +221,7 @@ describe("collectPublishablePluginPackageErrors", () => {
collectPublishablePluginPackageErrors({
extensionId: "voice-call",
packageDir: bundledPluginRoot("voice-call"),
readmeText: "# Voice call\n",
packageJson: {
name: "@openclaw/voice-call",
version: "2026.5.1-beta.1",
@@ -234,6 +245,34 @@ describe("collectPublishablePluginPackageErrors", () => {
"openclaw.build.openclawVersion is required for external code plugin packages.",
]);
});
it("requires package documentation before publishing", () => {
expect(
collectPublishablePluginPackageErrors({
extensionId: "zalo",
packageDir: bundledPluginRoot("zalo"),
readmeText: " \n",
packageJson: {
name: "@openclaw/zalo",
version: "2026.3.15",
repository: {
type: "git",
url: OPENCLAW_PLUGIN_NPM_REPOSITORY_URL,
},
openclaw: {
extensions: ["./index.ts"],
...externalPluginContract("2026.3.15"),
install: {
npmSpec: "@openclaw/zalo",
},
release: {
publishToNpm: true,
},
},
},
}),
).toEqual(["README.md must exist and contain package documentation."]);
});
});
describe("collectPluginReleaseVersionFloorErrors", () => {
@@ -291,6 +330,7 @@ describe("collectPublishablePluginPackages", () => {
it("collects publishable npm plugins from extension package manifests", () => {
const repoDir = makeTempRepoRoot(tempDirs, "openclaw-plugin-npm-release-");
mkdirSync(join(repoDir, "extensions", "demo-plugin"), { recursive: true });
writePluginReadme(repoDir, "demo-plugin");
writeJsonFile(join(repoDir, "extensions", "demo-plugin", "package.json"), {
name: "@openclaw/demo-plugin",
version: "2026.4.10",
@@ -326,6 +366,7 @@ describe("collectPublishablePluginPackages", () => {
it("does not validate unselected publishable plugin manifests", () => {
const repoDir = makeTempRepoRoot(tempDirs, "openclaw-plugin-npm-release-");
mkdirSync(join(repoDir, "extensions", "demo-plugin"), { recursive: true });
writePluginReadme(repoDir, "demo-plugin");
writeJsonFile(join(repoDir, "extensions", "demo-plugin", "package.json"), {
name: "@openclaw/demo-plugin",
version: "2026.4.10-beta.1",
@@ -404,6 +445,7 @@ describe("collectPublishablePluginPackages", () => {
it("publishes alpha plugin packages to the alpha dist-tag", () => {
const repoDir = makeTempRepoRoot(tempDirs, "openclaw-plugin-npm-release-");
mkdirSync(join(repoDir, "extensions", "demo-plugin"), { recursive: true });
writePluginReadme(repoDir, "demo-plugin");
writeJsonFile(join(repoDir, "extensions", "demo-plugin", "package.json"), {
name: "@openclaw/demo-plugin",
version: "2026.4.10-alpha.1",
@@ -1695,6 +1695,11 @@ describe("package artifact reuse", () => {
const clawHubNewWorkflow = readFileSync(".github/workflows/plugin-clawhub-new.yml", "utf8");
const pluginNpmWorkflow = readFileSync(".github/workflows/plugin-npm-release.yml", "utf8");
const openclawNpmWorkflow = readFileSync(".github/workflows/openclaw-npm-release.yml", "utf8");
const fastPretagScript = readFileSync("scripts/release-fast-pretag-check.sh", "utf8");
const pluginPretagPackScript = readFileSync(
"scripts/plugin-release-pretag-pack-check.ts",
"utf8",
);
const approvalScript = readFileSync("scripts/validate-release-publish-approval.mjs", "utf8");
const clawHubReleasePlanScript = readFileSync(
"scripts/lib/openclaw-release-clawhub-plan.ts",
@@ -1721,6 +1726,15 @@ describe("package artifact reuse", () => {
expect(packageJson.scripts?.["release:fast-pretag-check"]).toBe(
"bash scripts/release-fast-pretag-check.sh",
);
expect(fastPretagScript).toContain(
"node --import tsx scripts/plugin-release-pretag-pack-check.ts",
);
expect(fastPretagScript).not.toContain(
"check-plugin-npm-runtime-builds.mjs --package extensions/diffs-language-pack",
);
expect(pluginPretagPackScript).toContain("scripts/check-plugin-npm-runtime-builds.mjs");
expect(pluginPretagPackScript).toContain("scripts/plugin-npm-publish.sh");
expect(pluginPretagPackScript).toContain("scripts/plugin-clawhub-publish.sh");
expect(clawHubWorkflow).toContain('CLAWHUB_CLI_PACKAGE: "clawhub@0.21.0"');
expect(clawHubWorkflow).not.toContain("CLAWHUB_REPOSITORY:");
expect(clawHubWorkflow).not.toContain("CLAWHUB_REF:");