diff --git a/scripts/lib/plugin-clawhub-release.ts b/scripts/lib/plugin-clawhub-release.ts index 9ee029e6121c..3d67939a71e7 100644 --- a/scripts/lib/plugin-clawhub-release.ts +++ b/scripts/lib/plugin-clawhub-release.ts @@ -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, diff --git a/scripts/lib/plugin-npm-release.ts b/scripts/lib/plugin-npm-release.ts index 5fc110672822..57ffef1c73f3 100644 --- a/scripts/lib/plugin-npm-release.ts +++ b/scripts/lib/plugin-npm-release.ts @@ -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[] { - const extensionsDir = join(rootDir, "extensions"); - const dirs = readdirSync(extensionsDir, { withFileTypes: true }).filter((entry) => - entry.isDirectory(), - ); - - const candidates: PublishablePluginPackageCandidate[] = []; - 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("\\", "/"); } diff --git a/scripts/lib/plugin-publication-candidates.ts b/scripts/lib/plugin-publication-candidates.ts new file mode 100644 index 000000000000..3d3d1d475d4d --- /dev/null +++ b/scripts/lib/plugin-publication-candidates.ts @@ -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[] { + 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`, + ), + }, + ]; + }); +} diff --git a/scripts/release-plan-producer.mts b/scripts/release-plan-producer.mts index 59745d210af4..8ee3abdda0da 100644 --- a/scripts/release-plan-producer.mts +++ b/scripts/release-plan-producer.mts @@ -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 }>; @@ -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"); } diff --git a/test/scripts/release-plan-producer.test.ts b/test/scripts/release-plan-producer.test.ts index 5b21bf31d9d1..11eff8430639 100644 --- a/test/scripts/release-plan-producer.test.ts +++ b/test/scripts/release-plan-producer.test.ts @@ -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",