mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-25 11:55:47 -06:00
fix(release): verify plan authority remotely
This commit is contained in:
@@ -7,7 +7,6 @@ import { runTasksWithConcurrency } from "../../src/utils/run-with-concurrency.js
|
||||
import { readBoundedResponseText } from "./bounded-response.mjs";
|
||||
import {
|
||||
assertPluginReleaseDependencyFreshness,
|
||||
collectExtensionPackageJsonCandidates,
|
||||
collectChangedPathsFromGitRange,
|
||||
collectChangedExtensionIdsFromPaths,
|
||||
assertPluginReleaseVersionFloors,
|
||||
@@ -19,6 +18,7 @@ import {
|
||||
type NpmLatestVersionResolver,
|
||||
type PluginReleaseSelectionMode,
|
||||
} from "./plugin-npm-release.ts";
|
||||
import { collectExtensionPackageJsonCandidates } from "./plugin-publication-candidates.ts";
|
||||
import {
|
||||
collectPublishablePluginPackagesFromCandidates,
|
||||
type PluginPackageJson,
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
// Plugin Npm Release script supports OpenClaw repository automation.
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { mkdtempSync, readdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join, resolve } from "node:path";
|
||||
import { expectDefined } from "../../packages/normalization-core/src/expect.js";
|
||||
import { collectExtensionPackageJsonCandidates } from "./plugin-publication-candidates.ts";
|
||||
import {
|
||||
collectPublishablePluginPackagesFromCandidates,
|
||||
type PluginPackageJson,
|
||||
type PublishablePluginPackage,
|
||||
type PublishablePluginPackageCandidate,
|
||||
type PublishablePluginPackageFilters,
|
||||
} from "./plugin-publication-collector.ts";
|
||||
import { collectReleaseVersionFloorErrors } from "./release-version.mjs";
|
||||
@@ -20,6 +20,7 @@ export {
|
||||
OPENCLAW_PLUGIN_NPM_REPOSITORY_URL,
|
||||
resolvePublishablePluginVersion,
|
||||
} from "./plugin-publication-collector.ts";
|
||||
export { collectExtensionPackageJsonCandidates } from "./plugin-publication-candidates.ts";
|
||||
export type {
|
||||
PublishablePluginPackage,
|
||||
RequiredLatestDependency,
|
||||
@@ -70,42 +71,6 @@ 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>[] {
|
||||
const extensionsDir = join(rootDir, "extensions");
|
||||
const dirs = readdirSync(extensionsDir, { withFileTypes: true }).filter((entry) =>
|
||||
entry.isDirectory(),
|
||||
);
|
||||
|
||||
const candidates: PublishablePluginPackageCandidate<TPackageJson>[] = [];
|
||||
for (const dir of dirs) {
|
||||
const packageDir = `extensions/${dir.name}`;
|
||||
const absolutePackageDir = join(extensionsDir, dir.name);
|
||||
const packageJsonPath = join(absolutePackageDir, "package.json");
|
||||
try {
|
||||
candidates.push({
|
||||
extensionId: dir.name,
|
||||
packageDir,
|
||||
packageJson: readPluginPackageJson(packageJsonPath) as TPackageJson,
|
||||
readmeText: readOptionalTextFile(join(absolutePackageDir, "README.md")),
|
||||
});
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
return candidates;
|
||||
}
|
||||
|
||||
function normalizeGitDiffPath(path: string): string {
|
||||
return path.trim().replaceAll("\\", "/");
|
||||
}
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
import { readdirSync, readFileSync, statSync } from "node:fs";
|
||||
import { join, resolve } from "node:path";
|
||||
import type {
|
||||
PluginPackageJson,
|
||||
PublishablePluginPackageCandidate,
|
||||
} from "./plugin-publication-collector.ts";
|
||||
|
||||
function readPluginPackageJson(absolutePath: string, repoPath: string): PluginPackageJson {
|
||||
let raw: string;
|
||||
try {
|
||||
raw = readFileSync(absolutePath, "utf8");
|
||||
} catch (error) {
|
||||
throw new Error(`plugin candidate manifest is unreadable: ${repoPath}`, { cause: error });
|
||||
}
|
||||
try {
|
||||
return JSON.parse(raw) as PluginPackageJson;
|
||||
} catch (error) {
|
||||
throw new Error(`plugin candidate manifest is malformed JSON: ${repoPath}`, { cause: error });
|
||||
}
|
||||
}
|
||||
|
||||
function pluginPackageJsonExists(absolutePath: string, repoPath: string): boolean {
|
||||
try {
|
||||
statSync(absolutePath);
|
||||
return true;
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code === "ENOENT") {
|
||||
return false;
|
||||
}
|
||||
throw new Error(`plugin candidate manifest is unreadable: ${repoPath}`, { cause: error });
|
||||
}
|
||||
}
|
||||
|
||||
function readOptionalPluginReadme(absolutePath: string, repoPath: string): string | undefined {
|
||||
try {
|
||||
return readFileSync(absolutePath, "utf8");
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code === "ENOENT") {
|
||||
return undefined;
|
||||
}
|
||||
throw new Error(`plugin candidate README is unreadable: ${repoPath}`, { cause: error });
|
||||
}
|
||||
}
|
||||
|
||||
export function collectExtensionPackageJsonCandidates<
|
||||
TPackageJson extends PluginPackageJson = PluginPackageJson,
|
||||
>(rootDir = resolve(".")): PublishablePluginPackageCandidate<TPackageJson>[] {
|
||||
const extensionsDir = join(rootDir, "extensions");
|
||||
return readdirSync(extensionsDir, { withFileTypes: true }).flatMap((entry) => {
|
||||
if (!entry.isDirectory()) {
|
||||
return [];
|
||||
}
|
||||
const packageDir = `extensions/${entry.name}`;
|
||||
const absolutePackageDir = join(extensionsDir, entry.name);
|
||||
const packageJsonPath = join(absolutePackageDir, "package.json");
|
||||
if (!pluginPackageJsonExists(packageJsonPath, `${packageDir}/package.json`)) {
|
||||
return [];
|
||||
}
|
||||
return [
|
||||
{
|
||||
extensionId: entry.name,
|
||||
packageDir,
|
||||
packageJson: readPluginPackageJson(
|
||||
packageJsonPath,
|
||||
`${packageDir}/package.json`,
|
||||
) as TPackageJson,
|
||||
readmeText: readOptionalPluginReadme(
|
||||
join(absolutePackageDir, "README.md"),
|
||||
`${packageDir}/README.md`,
|
||||
),
|
||||
},
|
||||
];
|
||||
});
|
||||
}
|
||||
@@ -1,14 +1,14 @@
|
||||
#!/usr/bin/env node
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { existsSync, mkdtempSync, mkdirSync, readdirSync, readFileSync, rmSync } from "node:fs";
|
||||
import { existsSync, mkdtempSync, mkdirSync, readFileSync, rmSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join, posix, resolve } from "node:path";
|
||||
import { fileURLToPath, pathToFileURL } from "node:url";
|
||||
import { parse as parseYaml } from "yaml";
|
||||
import { collectExtensionPackageJsonCandidates } from "./lib/plugin-publication-candidates.ts";
|
||||
import {
|
||||
collectPublishablePluginPackagesFromCandidates,
|
||||
type PluginPackageJson,
|
||||
type PublishablePluginPackageCandidate,
|
||||
} from "./lib/plugin-publication-collector.ts";
|
||||
import { parseReleaseVersion } from "./lib/release-version.mjs";
|
||||
import {
|
||||
@@ -22,6 +22,7 @@ import {
|
||||
type ReleasePlanLock,
|
||||
type ReleasePlanPurpose,
|
||||
} from "./release-plan-contract.mjs";
|
||||
import { verifyReleaseToolingIdentity } from "./release-tooling-identity.mjs";
|
||||
|
||||
export type ReleasePlanIntent = "publish" | "postpublish-confidence" | "main-qualification";
|
||||
|
||||
@@ -32,6 +33,7 @@ type ReleasePlanSource = {
|
||||
candidateRef: string;
|
||||
toolingSha: string;
|
||||
toolingFullRef: string;
|
||||
runGh?: (args: string[]) => string;
|
||||
};
|
||||
|
||||
type PackageManifest = PluginPackageJson;
|
||||
@@ -279,29 +281,6 @@ function readPackageManifest(path: string): PackageManifest {
|
||||
return JSON.parse(readFileSync(path, "utf8")) as PackageManifest;
|
||||
}
|
||||
|
||||
function collectPluginCandidates(snapshotRoot: string): PublishablePluginPackageCandidate[] {
|
||||
return readdirSync(join(snapshotRoot, "extensions"), { withFileTypes: true }).flatMap((entry) => {
|
||||
if (!entry.isDirectory()) {
|
||||
return [];
|
||||
}
|
||||
const packageDir = `extensions/${entry.name}`;
|
||||
const absolutePackageDir = join(snapshotRoot, packageDir);
|
||||
const packageJsonPath = join(absolutePackageDir, "package.json");
|
||||
if (!existsSync(packageJsonPath)) {
|
||||
return [];
|
||||
}
|
||||
const readmePath = join(absolutePackageDir, "README.md");
|
||||
return [
|
||||
{
|
||||
extensionId: entry.name,
|
||||
packageDir,
|
||||
packageJson: readPackageManifest(packageJsonPath),
|
||||
...(existsSync(readmePath) ? { readmeText: readFileSync(readmePath, "utf8") } : {}),
|
||||
},
|
||||
];
|
||||
});
|
||||
}
|
||||
|
||||
function collectCorePackagePolicy(workflowText: string): CorePackagePolicy[] {
|
||||
const workflow = parseYaml(workflowText) as {
|
||||
jobs?: Record<string, { steps?: Array<{ env?: { CORE_PACKAGE_DIRS?: unknown } }> }>;
|
||||
@@ -373,7 +352,7 @@ function collectPackageInventory(
|
||||
packages.set(manifest.name, entry);
|
||||
};
|
||||
addPackage({ name: "openclaw", version }, ["npm"], "package.json");
|
||||
const pluginCandidates = collectPluginCandidates(snapshotRoot);
|
||||
const pluginCandidates = collectExtensionPackageJsonCandidates(snapshotRoot);
|
||||
for (const [target, plugins] of [
|
||||
["clawhub", collectPublishablePluginPackagesFromCandidates(pluginCandidates, "clawhub")],
|
||||
["npm", collectPublishablePluginPackagesFromCandidates(pluginCandidates, "npm")],
|
||||
@@ -478,20 +457,6 @@ function readCandidateInventory(
|
||||
});
|
||||
}
|
||||
|
||||
function requireToolingRoute(intent: ReleasePlanIntent, ref: string, toolingSha: string) {
|
||||
const protectedMatch = /^refs\/tags\/release-publish\/([a-f0-9]{12})-[1-9][0-9]*$/u.exec(ref);
|
||||
const protectedRoute = protectedMatch?.[1] === toolingSha.slice(0, 12);
|
||||
if (intent === "main-qualification") {
|
||||
if (ref !== "refs/heads/main" && !protectedRoute) {
|
||||
throw new Error("main qualification tooling must use trusted main or release-publish tag");
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (!protectedRoute) {
|
||||
throw new Error(`${intent} tooling must use a release-publish tag bound to its SHA`);
|
||||
}
|
||||
}
|
||||
|
||||
function resolveSource(params: ReleasePlanSource) {
|
||||
const repoRoot = resolve(params.repoRoot ?? ".");
|
||||
const candidateSha = requireExactSha(params.candidateSha, "candidate SHA");
|
||||
@@ -500,7 +465,17 @@ function resolveSource(params: ReleasePlanSource) {
|
||||
if (resolveCommit(repoRoot, candidateSha, "candidate SHA") !== candidateSha) {
|
||||
throw new Error("candidate SHA does not resolve to itself");
|
||||
}
|
||||
requireToolingRoute(params.intent, toolingFullRef, toolingSha);
|
||||
const toolingRef = toolingFullRef.replace(/^refs\/(?:heads|tags)\//u, "");
|
||||
const verifiedTooling = verifyReleaseToolingIdentity({
|
||||
repository: REPOSITORY,
|
||||
workflowFullRef: toolingFullRef,
|
||||
workflowRef: toolingRef,
|
||||
workflowSha: toolingSha,
|
||||
...(params.runGh ? { runGh: params.runGh } : {}),
|
||||
});
|
||||
if (params.intent !== "main-qualification" && verifiedTooling.route !== "protected-tag") {
|
||||
throw new Error(`${params.intent} tooling must use a release-publish tag bound to its SHA`);
|
||||
}
|
||||
if (resolveCommit(repoRoot, toolingFullRef, "tooling full ref") !== toolingSha) {
|
||||
throw new Error("tooling full ref does not resolve to the requested tooling SHA");
|
||||
}
|
||||
|
||||
@@ -2,6 +2,8 @@ import { execFileSync } from "node:child_process";
|
||||
import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
||||
import { dirname, join, resolve } from "node:path";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { collectClawHubPublishablePluginPackages } from "../../scripts/lib/plugin-clawhub-release.ts";
|
||||
import { collectPublishablePluginPackages } from "../../scripts/lib/plugin-npm-release.ts";
|
||||
import {
|
||||
canonicalReleasePlanLockJson,
|
||||
createReleasePlanLock,
|
||||
@@ -21,7 +23,9 @@ const TOOLING_CLOSURE = [
|
||||
"packages/plugin-package-contract/src/index.ts",
|
||||
"scripts/release-plan-producer.mts",
|
||||
"scripts/release-plan-contract.mjs",
|
||||
"scripts/release-tooling-identity.mjs",
|
||||
"scripts/lib/npm-publish-plan.mjs",
|
||||
"scripts/lib/plugin-publication-candidates.ts",
|
||||
"scripts/lib/plugin-publication-collector.ts",
|
||||
"scripts/lib/record-shared.mjs",
|
||||
"scripts/lib/release-version.mjs",
|
||||
@@ -61,7 +65,7 @@ function copyToolingClosure(root: string) {
|
||||
|
||||
function createFixtureRepo(
|
||||
version = "2026.8.1-beta.2",
|
||||
options: { malformedPlugin?: boolean } = {},
|
||||
options: { malformedPlugin?: boolean; malformedPluginJson?: boolean } = {},
|
||||
) {
|
||||
const root = tempDirs.make("openclaw-release-plan-");
|
||||
execFileSync("git", ["init", "-q", "-b", "tooling"], { cwd: root });
|
||||
@@ -90,7 +94,9 @@ function createFixtureRepo(
|
||||
}),
|
||||
);
|
||||
}
|
||||
if (options.malformedPlugin) {
|
||||
if (options.malformedPluginJson) {
|
||||
writeFixture(root, "extensions/broken/package.json", "{ not-json\n");
|
||||
} else if (options.malformedPlugin) {
|
||||
writeFixture(
|
||||
root,
|
||||
"extensions/broken/package.json",
|
||||
@@ -200,9 +206,26 @@ function sourceParams(
|
||||
candidateRef: intent === "main-qualification" ? fixture.candidateSha : fixture.candidateRef,
|
||||
toolingSha: fixture.toolingSha,
|
||||
toolingFullRef: fixture.toolingFullRef,
|
||||
runGh: trustedToolingGh(fixture.toolingFullRef, fixture.toolingSha),
|
||||
} as const;
|
||||
}
|
||||
|
||||
function trustedToolingGh(toolingFullRef: string, toolingSha: string) {
|
||||
return (args: string[]) => {
|
||||
const endpoint = args[1];
|
||||
if (
|
||||
endpoint ===
|
||||
`repos/openclaw/openclaw/git/ref/tags/${toolingFullRef.slice("refs/tags/".length)}`
|
||||
) {
|
||||
return JSON.stringify({
|
||||
ref: toolingFullRef,
|
||||
object: { type: "commit", sha: toolingSha },
|
||||
});
|
||||
}
|
||||
throw new Error(`unexpected GitHub API request: ${args.join(" ")}`);
|
||||
};
|
||||
}
|
||||
|
||||
describe("release plan producer", () => {
|
||||
it("derives purpose, profile, tag, and soak from the canonical version parser", () => {
|
||||
expect(deriveReleasePlanPolicy("publish", "2026.8.1-beta.2")).toEqual({
|
||||
@@ -316,6 +339,7 @@ describe("release plan producer", () => {
|
||||
...sourceParams(fixture),
|
||||
toolingSha: fixture.candidateSha,
|
||||
toolingFullRef: mismatchedToolingRef,
|
||||
runGh: trustedToolingGh(mismatchedToolingRef, fixture.candidateSha),
|
||||
}),
|
||||
).toThrow("tooling full ref does not resolve");
|
||||
expect(() =>
|
||||
@@ -323,6 +347,24 @@ describe("release plan producer", () => {
|
||||
).toThrow("candidate ref must be");
|
||||
});
|
||||
|
||||
it("rejects a locally forged protected tooling tag that GitHub does not own", () => {
|
||||
const fixture = createFixtureRepo();
|
||||
const forgedFullRef = `refs/tags/release-publish/${fixture.toolingSha.slice(0, 12)}-999`;
|
||||
execFileSync("git", ["tag", forgedFullRef.slice("refs/tags/".length), fixture.toolingSha], {
|
||||
cwd: fixture.root,
|
||||
});
|
||||
|
||||
expect(() =>
|
||||
produceReleasePlan({
|
||||
...sourceParams(fixture),
|
||||
toolingFullRef: forgedFullRef,
|
||||
runGh: () => {
|
||||
throw new Error("HTTP 404");
|
||||
},
|
||||
}),
|
||||
).toThrow("protected release tooling tag is missing or unreadable");
|
||||
});
|
||||
|
||||
it("rejects a caller producer that differs from the exact tooling commit", () => {
|
||||
const fixture = createFixtureRepo();
|
||||
writeFixture(fixture.root, "scripts/release-plan-producer.mts", "// placeholder producer\n");
|
||||
@@ -337,6 +379,7 @@ describe("release plan producer", () => {
|
||||
...sourceParams(fixture),
|
||||
toolingSha,
|
||||
toolingFullRef,
|
||||
runGh: trustedToolingGh(toolingFullRef, toolingSha),
|
||||
}),
|
||||
).toThrow("tooling import closure differs from tooling SHA");
|
||||
});
|
||||
@@ -348,7 +391,15 @@ describe("release plan producer", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("matches the current publisher inventory: 93 npm and 89 ClawHub packages", () => {
|
||||
it("fails closed on malformed candidate manifests across both publishers and ReleasePlan", () => {
|
||||
const fixture = createFixtureRepo("2026.8.1-beta.2", { malformedPluginJson: true });
|
||||
const error = "plugin candidate manifest is malformed JSON: extensions/broken/package.json";
|
||||
expect(() => collectPublishablePluginPackages(fixture.root)).toThrow(error);
|
||||
expect(() => collectClawHubPublishablePluginPackages(fixture.root)).toThrow(error);
|
||||
expect(() => produceReleasePlan(sourceParams(fixture))).toThrow(error);
|
||||
});
|
||||
|
||||
it("matches the exact current publisher inventory: 93 npm and 89 ClawHub packages", () => {
|
||||
const root = tempDirs.make("openclaw-release-plan-current-");
|
||||
const candidateSha = execFileSync("git", ["rev-parse", "HEAD"], {
|
||||
cwd: resolve("."),
|
||||
@@ -367,6 +418,7 @@ describe("release plan producer", () => {
|
||||
candidateRef: candidateSha,
|
||||
toolingSha,
|
||||
toolingFullRef: "refs/heads/main",
|
||||
runGh: () => JSON.stringify({ status: "identical" }),
|
||||
});
|
||||
const npmPackages = plan.inventory.packages.filter((entry) => entry.targets.includes("npm"));
|
||||
const clawHubPackages = plan.inventory.packages.filter((entry) =>
|
||||
@@ -374,6 +426,27 @@ describe("release plan producer", () => {
|
||||
);
|
||||
expect(npmPackages).toHaveLength(93);
|
||||
expect(clawHubPackages).toHaveLength(89);
|
||||
const coreNpmPackages = new Set([
|
||||
"@openclaw/ai",
|
||||
"@openclaw/gateway-client",
|
||||
"@openclaw/gateway-protocol",
|
||||
"openclaw",
|
||||
]);
|
||||
expect(
|
||||
npmPackages
|
||||
.map((entry) => entry.name)
|
||||
.filter((name) => !coreNpmPackages.has(name))
|
||||
.toSorted(),
|
||||
).toEqual(
|
||||
collectPublishablePluginPackages(root)
|
||||
.map((plugin) => plugin.packageName)
|
||||
.toSorted(),
|
||||
);
|
||||
expect(clawHubPackages.map((entry) => entry.name).toSorted()).toEqual(
|
||||
collectClawHubPublishablePluginPackages(root)
|
||||
.map((plugin) => plugin.packageName)
|
||||
.toSorted(),
|
||||
);
|
||||
expect(npmPackages.map((entry) => entry.name)).toEqual(
|
||||
expect.arrayContaining([
|
||||
"@openclaw/ai",
|
||||
|
||||
Reference in New Issue
Block a user