From 5b18b7560e67d40155d73bc7306f0fcd6029d33c Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Mon, 15 Jun 2026 13:57:26 +0800 Subject: [PATCH] fix(release): harden plugin package preflight --- .../release-openclaw-maintainer/SKILL.md | 23 +++- extensions/llama-cpp/README.md | 27 ++++ scripts/lib/plugin-clawhub-release.ts | 6 +- scripts/lib/plugin-npm-package-manifest.mjs | 7 +- scripts/lib/plugin-npm-release.ts | 13 ++ scripts/plugin-release-pretag-pack-check.ts | 126 ++++++++++++++++++ scripts/release-fast-pretag-check.sh | 2 +- test/plugin-clawhub-release.test.ts | 1 + test/plugin-npm-package-manifest.test.ts | 3 +- test/plugin-npm-release.test.ts | 44 +++++- .../package-acceptance-workflow.test.ts | 14 ++ 11 files changed, 254 insertions(+), 12 deletions(-) create mode 100644 extensions/llama-cpp/README.md create mode 100644 scripts/plugin-release-pretag-pack-check.ts diff --git a/.agents/skills/release-openclaw-maintainer/SKILL.md b/.agents/skills/release-openclaw-maintainer/SKILL.md index cd19b387422..6e84a798f51 100644 --- a/.agents/skills/release-openclaw-maintainer/SKILL.md +++ b/.agents/skills/release-openclaw-maintainer/SKILL.md @@ -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 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. diff --git a/extensions/llama-cpp/README.md b/extensions/llama-cpp/README.md new file mode 100644 index 00000000000..7960c7b8eaf --- /dev/null +++ b/extensions/llama-cpp/README.md @@ -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` diff --git a/scripts/lib/plugin-clawhub-release.ts b/scripts/lib/plugin-clawhub-release.ts index 287f624ea1f..d46e40c1046 100644 --- a/scripts/lib/plugin-clawhub-release.ts +++ b/scripts/lib/plugin-clawhub-release.ts @@ -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; diff --git a/scripts/lib/plugin-npm-package-manifest.mjs b/scripts/lib/plugin-npm-package-manifest.mjs index d2b0f4de8c6..fd0818e7bfb 100644 --- a/scripts/lib/plugin-npm-package-manifest.mjs +++ b/scripts/lib/plugin-npm-package-manifest.mjs @@ -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)) { diff --git a/scripts/lib/plugin-npm-release.ts b/scripts/lib/plugin-npm-release.ts index dc09856549e..76e09280d16 100644 --- a/scripts/lib/plugin-npm-release.ts +++ b/scripts/lib/plugin-npm-release.ts @@ -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[] { @@ -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 || ""}".`, diff --git a/scripts/plugin-release-pretag-pack-check.ts b/scripts/plugin-release-pretag-pack-check.ts new file mode 100644 index 00000000000..4c382d90a58 --- /dev/null +++ b/scripts/plugin-release-pretag-pack-check.ts @@ -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(); + + 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(); +} diff --git a/scripts/release-fast-pretag-check.sh b/scripts/release-fast-pretag-check.sh index 2a5eb870eec..9831785efd0 100755 --- a/scripts/release-fast-pretag-check.sh +++ b/scripts/release-fast-pretag-check.sh @@ -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 diff --git a/test/plugin-clawhub-release.test.ts b/test/plugin-clawhub-release.test.ts index c3d77897001..9a99f99812e 100644 --- a/test/plugin-clawhub-release.test.ts +++ b/test/plugin-clawhub-release.test.ts @@ -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"]); diff --git a/test/plugin-npm-package-manifest.test.ts b/test/plugin-npm-package-manifest.test.ts index 01bdaf7af77..32e41cc8ff3 100644 --- a/test/plugin-npm-package-manifest.test.ts +++ b/test/plugin-npm-package-manifest.test.ts @@ -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(); diff --git a/test/plugin-npm-release.test.ts b/test/plugin-npm-release.test.ts index 7a1b5375936..ff49928812f 100644 --- a/test/plugin-npm-release.test.ts +++ b/test/plugin-npm-release.test.ts @@ -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", diff --git a/test/scripts/package-acceptance-workflow.test.ts b/test/scripts/package-acceptance-workflow.test.ts index c06d614c38a..9d4a373118b 100644 --- a/test/scripts/package-acceptance-workflow.test.ts +++ b/test/scripts/package-acceptance-workflow.test.ts @@ -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:");