mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 05:20:43 +00:00
fix(release): verify published plugin runtime tarballs
This commit is contained in:
7
.github/workflows/plugin-npm-release.yml
vendored
7
.github/workflows/plugin-npm-release.yml
vendored
@@ -13,6 +13,7 @@ on:
|
||||
- "scripts/plugin-npm-publish.sh"
|
||||
- "scripts/plugin-npm-release-check.ts"
|
||||
- "scripts/plugin-npm-release-plan.ts"
|
||||
- "scripts/verify-plugin-npm-published-runtime.mjs"
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
publish_scope:
|
||||
@@ -224,3 +225,9 @@ jobs:
|
||||
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
|
||||
NPM_TOKEN: ${{ secrets.NPM_TOKEN }}
|
||||
run: bash scripts/plugin-npm-publish.sh --publish "${{ matrix.plugin.packageDir }}"
|
||||
|
||||
- name: Verify published runtime
|
||||
env:
|
||||
PACKAGE_NAME: ${{ matrix.plugin.packageName }}
|
||||
PACKAGE_VERSION: ${{ matrix.plugin.version }}
|
||||
run: node scripts/verify-plugin-npm-published-runtime.mjs "${PACKAGE_NAME}@${PACKAGE_VERSION}"
|
||||
|
||||
@@ -30,6 +30,7 @@ Docs: https://docs.openclaw.ai
|
||||
### Fixes
|
||||
|
||||
- Agents/tools: stop treating `tools.deny: ["write"]` as an implicit `apply_patch` deny; operators who want to block patch writes should deny `apply_patch` or `group:fs` explicitly. Fixes #76749. (#76795) Thanks @Nek-12 and @hclsys.
|
||||
- Plugins/release: verify published plugin npm tarballs expose compiled runtime entries after publish, catching TS-only package artifacts before release closeout. Thanks @vincentkoc.
|
||||
- Gateway/update: recover an installed-but-unloaded macOS LaunchAgent after package updates, rerun Gateway health/version/channel readiness checks, and print restart, reinstall, and rollback guidance before reporting update failure. (#76790) Thanks @jonathanlindsay.
|
||||
- CLI/plugins: explain when a missing plugin command alias belongs to a bundled plugin that is disabled by default, including the `openclaw plugins enable <plugin>` repair command. (#76835)
|
||||
- Google Meet: route stateful CLI session commands through the gateway-owned runtime so joined realtime sessions survive after the starting CLI process exits. Fixes #76344. Thanks @coltonharris-wq.
|
||||
|
||||
208
scripts/verify-plugin-npm-published-runtime.mjs
Normal file
208
scripts/verify-plugin-npm-published-runtime.mjs
Normal file
@@ -0,0 +1,208 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import { execFileSync } from "node:child_process";
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { pathToFileURL } from "node:url";
|
||||
import * as tar from "tar";
|
||||
|
||||
function normalizeStringList(value) {
|
||||
if (!Array.isArray(value)) {
|
||||
return [];
|
||||
}
|
||||
return value.map((entry) => (typeof entry === "string" ? entry.trim() : "")).filter(Boolean);
|
||||
}
|
||||
|
||||
function normalizePackagePath(value) {
|
||||
return value
|
||||
.replace(/\\/g, "/")
|
||||
.replace(/^package\//u, "")
|
||||
.replace(/^\.\//u, "");
|
||||
}
|
||||
|
||||
function isTypeScriptPackageEntry(entryPath) {
|
||||
return [".ts", ".mts", ".cts"].includes(path.extname(entryPath).toLowerCase());
|
||||
}
|
||||
|
||||
function listBuiltRuntimeEntryCandidates(entryPath) {
|
||||
if (!isTypeScriptPackageEntry(entryPath)) {
|
||||
return [];
|
||||
}
|
||||
const normalized = entryPath.replace(/\\/g, "/");
|
||||
const withoutExtension = normalized.replace(/\.[^.]+$/u, "");
|
||||
const normalizedRelative = normalized.replace(/^\.\//u, "");
|
||||
const distWithoutExtension = normalizedRelative.startsWith("src/")
|
||||
? `./dist/${normalizedRelative.slice("src/".length).replace(/\.[^.]+$/u, "")}`
|
||||
: `./dist/${withoutExtension.replace(/^\.\//u, "")}`;
|
||||
const withJavaScriptExtensions = (basePath) => [
|
||||
`${basePath}.js`,
|
||||
`${basePath}.mjs`,
|
||||
`${basePath}.cjs`,
|
||||
];
|
||||
return [
|
||||
...new Set([
|
||||
...withJavaScriptExtensions(distWithoutExtension),
|
||||
...withJavaScriptExtensions(withoutExtension),
|
||||
]),
|
||||
].filter((candidate) => candidate !== normalized);
|
||||
}
|
||||
|
||||
function formatPackageLabel(packageJson, fallbackSpec) {
|
||||
const packageName = typeof packageJson.name === "string" ? packageJson.name.trim() : "";
|
||||
const packageVersion = typeof packageJson.version === "string" ? packageJson.version.trim() : "";
|
||||
if (packageName && packageVersion) {
|
||||
return `${packageName}@${packageVersion}`;
|
||||
}
|
||||
return packageName || fallbackSpec || "<package>";
|
||||
}
|
||||
|
||||
export function collectPluginNpmPublishedRuntimeErrors(params) {
|
||||
const packageJson = params.packageJson ?? {};
|
||||
const packageFiles = new Set([...params.files].map(normalizePackagePath));
|
||||
const packageLabel = formatPackageLabel(packageJson, params.spec);
|
||||
const extensions = normalizeStringList(packageJson.openclaw?.extensions);
|
||||
const runtimeExtensions = normalizeStringList(packageJson.openclaw?.runtimeExtensions);
|
||||
const errors = [];
|
||||
|
||||
if (extensions.length === 0) {
|
||||
return errors;
|
||||
}
|
||||
|
||||
if (runtimeExtensions.length > 0 && runtimeExtensions.length !== extensions.length) {
|
||||
errors.push(
|
||||
`${packageLabel} package.json openclaw.runtimeExtensions length (${runtimeExtensions.length}) must match openclaw.extensions length (${extensions.length})`,
|
||||
);
|
||||
return errors;
|
||||
}
|
||||
|
||||
for (const [index, entry] of extensions.entries()) {
|
||||
const runtimeEntry = runtimeExtensions[index];
|
||||
if (runtimeEntry) {
|
||||
if (!packageFiles.has(normalizePackagePath(runtimeEntry))) {
|
||||
errors.push(`${packageLabel} runtime extension entry not found: ${runtimeEntry}`);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!isTypeScriptPackageEntry(entry)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const candidates = listBuiltRuntimeEntryCandidates(entry);
|
||||
if (candidates.some((candidate) => packageFiles.has(normalizePackagePath(candidate)))) {
|
||||
continue;
|
||||
}
|
||||
|
||||
errors.push(
|
||||
`${packageLabel} requires compiled runtime output for TypeScript entry ${entry}: expected ${candidates.join(", ")}`,
|
||||
);
|
||||
}
|
||||
|
||||
return errors;
|
||||
}
|
||||
|
||||
function npmPack(spec, destinationDir) {
|
||||
const output = execFileSync(
|
||||
"npm",
|
||||
["pack", spec, "--json", "--ignore-scripts", "--pack-destination", destinationDir],
|
||||
{
|
||||
encoding: "utf8",
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
},
|
||||
);
|
||||
const rows = JSON.parse(output);
|
||||
const filename = rows?.[0]?.filename;
|
||||
if (typeof filename !== "string" || !filename) {
|
||||
throw new Error(`npm pack ${spec} did not report a tarball filename`);
|
||||
}
|
||||
return path.isAbsolute(filename) ? filename : path.join(destinationDir, filename);
|
||||
}
|
||||
|
||||
function sleep(ms) {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
async function packPublishedPackage(spec, destinationDir) {
|
||||
const attempts = Number.parseInt(process.env.OPENCLAW_PLUGIN_NPM_VERIFY_ATTEMPTS ?? "6", 10);
|
||||
const delayMs = Number.parseInt(process.env.OPENCLAW_PLUGIN_NPM_VERIFY_DELAY_MS ?? "5000", 10);
|
||||
let lastError;
|
||||
for (let attempt = 1; attempt <= attempts; attempt += 1) {
|
||||
try {
|
||||
return npmPack(spec, destinationDir);
|
||||
} catch (error) {
|
||||
lastError = error;
|
||||
if (attempt < attempts) {
|
||||
await sleep(delayMs);
|
||||
}
|
||||
}
|
||||
}
|
||||
throw lastError;
|
||||
}
|
||||
|
||||
function listFiles(rootDir, prefix = "") {
|
||||
const files = [];
|
||||
for (const entry of fs.readdirSync(path.join(rootDir, prefix), { withFileTypes: true })) {
|
||||
const relativePath = path.join(prefix, entry.name).replace(/\\/g, "/");
|
||||
if (entry.isDirectory()) {
|
||||
files.push(...listFiles(rootDir, relativePath));
|
||||
} else if (entry.isFile()) {
|
||||
files.push(relativePath);
|
||||
}
|
||||
}
|
||||
return files;
|
||||
}
|
||||
|
||||
async function readPackedPackage(tarballPath, extractDir) {
|
||||
await tar.x({ file: tarballPath, cwd: extractDir });
|
||||
const packageDir = path.join(extractDir, "package");
|
||||
const packageJson = JSON.parse(fs.readFileSync(path.join(packageDir, "package.json"), "utf8"));
|
||||
return {
|
||||
packageJson,
|
||||
files: listFiles(packageDir),
|
||||
};
|
||||
}
|
||||
|
||||
export async function verifyPublishedPluginRuntime(spec) {
|
||||
const workingDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-plugin-npm-runtime."));
|
||||
try {
|
||||
const tarballPath = await packPublishedPackage(spec, workingDir);
|
||||
const extractDir = path.join(workingDir, "extract");
|
||||
fs.mkdirSync(extractDir, { recursive: true });
|
||||
const packedPackage = await readPackedPackage(tarballPath, extractDir);
|
||||
const errors = collectPluginNpmPublishedRuntimeErrors({
|
||||
...packedPackage,
|
||||
spec,
|
||||
});
|
||||
if (errors.length > 0) {
|
||||
throw new Error(errors.join("\n"));
|
||||
}
|
||||
return {
|
||||
packageName: packedPackage.packageJson.name,
|
||||
version: packedPackage.packageJson.version,
|
||||
fileCount: packedPackage.files.length,
|
||||
};
|
||||
} finally {
|
||||
fs.rmSync(workingDir, { force: true, recursive: true });
|
||||
}
|
||||
}
|
||||
|
||||
async function main(argv) {
|
||||
const spec = argv[0]?.trim();
|
||||
if (!spec) {
|
||||
throw new Error("Usage: node scripts/verify-plugin-npm-published-runtime.mjs <package-spec>");
|
||||
}
|
||||
const result = await verifyPublishedPluginRuntime(spec);
|
||||
console.log(
|
||||
`plugin-npm-published-runtime-check: ${result.packageName}@${result.version} OK (${result.fileCount} files)`,
|
||||
);
|
||||
}
|
||||
|
||||
if (import.meta.url === pathToFileURL(process.argv[1] ?? "").href) {
|
||||
main(process.argv.slice(2)).catch((error) => {
|
||||
console.error(
|
||||
`plugin-npm-published-runtime-check: ${error instanceof Error ? error.message : String(error)}`,
|
||||
);
|
||||
process.exitCode = 1;
|
||||
});
|
||||
}
|
||||
72
test/scripts/verify-plugin-npm-published-runtime.test.ts
Normal file
72
test/scripts/verify-plugin-npm-published-runtime.test.ts
Normal file
@@ -0,0 +1,72 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { collectPluginNpmPublishedRuntimeErrors } from "../../scripts/verify-plugin-npm-published-runtime.mjs";
|
||||
|
||||
describe("collectPluginNpmPublishedRuntimeErrors", () => {
|
||||
it("flags published plugin packages with TypeScript entries and no compiled runtime output", () => {
|
||||
expect(
|
||||
collectPluginNpmPublishedRuntimeErrors({
|
||||
spec: "@openclaw/discord@2026.5.2",
|
||||
packageJson: {
|
||||
name: "@openclaw/discord",
|
||||
version: "2026.5.2",
|
||||
openclaw: {
|
||||
extensions: ["./index.ts"],
|
||||
},
|
||||
},
|
||||
files: ["package.json", "index.ts"],
|
||||
}),
|
||||
).toEqual([
|
||||
"@openclaw/discord@2026.5.2 requires compiled runtime output for TypeScript entry ./index.ts: expected ./dist/index.js, ./dist/index.mjs, ./dist/index.cjs, ./index.js, ./index.mjs, ./index.cjs",
|
||||
]);
|
||||
});
|
||||
|
||||
it("accepts published plugin packages with explicit runtimeExtensions", () => {
|
||||
expect(
|
||||
collectPluginNpmPublishedRuntimeErrors({
|
||||
packageJson: {
|
||||
name: "@openclaw/zalo",
|
||||
version: "2026.5.3",
|
||||
openclaw: {
|
||||
extensions: ["./index.ts"],
|
||||
runtimeExtensions: ["./dist/index.js"],
|
||||
},
|
||||
},
|
||||
files: ["package.json", "index.ts", "dist/index.js"],
|
||||
}),
|
||||
).toEqual([]);
|
||||
});
|
||||
|
||||
it("flags missing explicit runtimeExtensions outputs", () => {
|
||||
expect(
|
||||
collectPluginNpmPublishedRuntimeErrors({
|
||||
packageJson: {
|
||||
name: "@openclaw/line",
|
||||
version: "2026.5.3",
|
||||
openclaw: {
|
||||
extensions: ["./src/index.ts"],
|
||||
runtimeExtensions: ["./dist/index.js"],
|
||||
},
|
||||
},
|
||||
files: ["package.json", "src/index.ts"],
|
||||
}),
|
||||
).toEqual(["@openclaw/line@2026.5.3 runtime extension entry not found: ./dist/index.js"]);
|
||||
});
|
||||
|
||||
it("flags runtimeExtensions length mismatches", () => {
|
||||
expect(
|
||||
collectPluginNpmPublishedRuntimeErrors({
|
||||
packageJson: {
|
||||
name: "@openclaw/acpx",
|
||||
version: "2026.5.3",
|
||||
openclaw: {
|
||||
extensions: ["./index.ts", "./tools.ts"],
|
||||
runtimeExtensions: ["./dist/index.js"],
|
||||
},
|
||||
},
|
||||
files: ["package.json", "dist/index.js"],
|
||||
}),
|
||||
).toEqual([
|
||||
"@openclaw/acpx@2026.5.3 package.json openclaw.runtimeExtensions length (1) must match openclaw.extensions length (2)",
|
||||
]);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user