fix(release): verify plan authority remotely

This commit is contained in:
Vincent Koc
2026-08-20 23:32:23 -07:00
parent ec3c11cbc6
commit fcceeae311
5 changed files with 170 additions and 83 deletions
+1 -1
View File
@@ -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,
+3 -38
View File
@@ -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`,
),
},
];
});
}
+16 -41
View File
@@ -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");
}
+76 -3
View File
@@ -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",