Files
openclaw/test/plugin-clawhub-release.test.ts
2026-06-18 16:50:56 +08:00

1291 lines
38 KiB
TypeScript

// Plugin ClawHub release tests validate plugin release metadata and artifacts.
import { execFileSync } from "node:child_process";
import {
chmodSync,
existsSync,
mkdirSync,
readFileSync,
realpathSync,
writeFileSync,
} from "node:fs";
import { delimiter, join } from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import {
buildOpenClawReleaseClawHubPlan,
buildOpenClawReleaseClawHubRuntimeState,
parseOpenClawReleaseClawHubPlanArgs,
} from "../scripts/lib/openclaw-release-clawhub-plan.ts";
import {
collectClawHubPublishablePluginPackages,
collectClawHubVersionGateErrors,
collectPluginClawHubReleasePathsFromGitRange,
collectPluginClawHubReleasePlan,
resolveChangedClawHubPublishablePluginPackages,
resolveSelectedClawHubPublishablePluginPackages,
type PublishablePluginPackage,
} from "../scripts/lib/plugin-clawhub-release.ts";
import {
collectPublishablePluginPackages,
OPENCLAW_PLUGIN_NPM_REPOSITORY_URL,
} from "../scripts/lib/plugin-npm-release.ts";
import { cleanupTempDirs, makeTempRepoRoot } from "./helpers/temp-repo.js";
const tempDirs: string[] = [];
afterEach(() => {
cleanupTempDirs(tempDirs);
});
describe("resolveChangedClawHubPublishablePluginPackages", () => {
const publishablePlugins: PublishablePluginPackage[] = [
{
extensionId: "feishu",
packageDir: "extensions/feishu",
packageName: "@openclaw/feishu",
version: "2026.4.1",
channel: "stable",
publishTag: "latest",
},
{
extensionId: "zalo",
packageDir: "extensions/zalo",
packageName: "@openclaw/zalo",
version: "2026.4.1-beta.1",
channel: "beta",
publishTag: "beta",
},
];
it("ignores shared release-tooling changes", () => {
expect(
resolveChangedClawHubPublishablePluginPackages({
plugins: publishablePlugins,
changedPaths: ["pnpm-lock.yaml"],
}),
).toStrictEqual([]);
});
});
describe("collectClawHubPublishablePluginPackages", () => {
it("requires the ClawHub external plugin contract", () => {
const repoDir = createTempPluginRepo({
includeClawHubContract: false,
});
expect(() => collectClawHubPublishablePluginPackages(repoDir)).toThrow(
"openclaw.compat.pluginApi is required for external code plugin packages.",
);
});
it("rejects unsafe extension directory names", () => {
const repoDir = createTempPluginRepo({
extensionId: "Demo Plugin",
});
expect(() => collectClawHubPublishablePluginPackages(repoDir)).toThrow(
"Demo Plugin: extension directory name must match",
);
});
it("validates only selected package names when filters are provided", () => {
const repoDir = createTempPluginRepo({
extraExtensionIds: ["broken-plugin"],
});
writeFileSync(
join(repoDir, "extensions", "broken-plugin", "package.json"),
JSON.stringify(
{
name: "@openclaw/broken-plugin",
version: "2026.4.1",
openclaw: {
extensions: ["./index.ts"],
release: {
publishToClawHub: true,
},
},
},
null,
2,
),
);
expect(
collectClawHubPublishablePluginPackages(repoDir, {
packageNames: ["@openclaw/demo-plugin"],
}).map((plugin) => plugin.packageName),
).toEqual(["@openclaw/demo-plugin"]);
});
});
describe("OpenClaw dual-published plugin metadata", () => {
const dualPublishedPlugins = [
{
extensionId: "cohere",
packageName: "@openclaw/cohere-provider",
install: {
clawhubSpec: "clawhub:@openclaw/cohere-provider",
defaultChoice: "npm",
minHostVersion: ">=2026.6.8",
npmSpec: "@openclaw/cohere-provider",
},
},
{
extensionId: "diagnostics-otel",
packageName: "@openclaw/diagnostics-otel",
install: {
clawhubSpec: "clawhub:@openclaw/diagnostics-otel",
defaultChoice: "npm",
minHostVersion: ">=2026.4.25",
npmSpec: "@openclaw/diagnostics-otel",
},
},
{
extensionId: "diagnostics-prometheus",
packageName: "@openclaw/diagnostics-prometheus",
install: {
clawhubSpec: "clawhub:@openclaw/diagnostics-prometheus",
defaultChoice: "npm",
minHostVersion: ">=2026.4.25",
npmSpec: "@openclaw/diagnostics-prometheus",
},
},
{
extensionId: "gmi",
packageName: "@openclaw/gmi-provider",
install: {
clawhubSpec: "clawhub:@openclaw/gmi-provider",
defaultChoice: "npm",
minHostVersion: ">=2026.6.8",
npmSpec: "@openclaw/gmi-provider",
},
},
] as const;
it("keeps dual-published plugins selectable through both ClawHub and npm release paths", () => {
const packageNames = dualPublishedPlugins.map((plugin) => plugin.packageName);
const clawHubPublishable = collectClawHubPublishablePluginPackages(undefined, {
packageNames,
});
const npmPublishable = collectPublishablePluginPackages(undefined, {
packageNames,
});
expect(clawHubPublishable.map((plugin) => plugin.packageName)).toEqual(packageNames);
expect(npmPublishable.map((plugin) => plugin.packageName)).toEqual(packageNames);
for (const plugin of dualPublishedPlugins) {
const packageJson = JSON.parse(
readFileSync(`extensions/${plugin.extensionId}/package.json`, "utf8"),
) as {
openclaw?: {
install?: {
clawhubSpec?: string;
defaultChoice?: string;
minHostVersion?: string;
npmSpec?: string;
};
release?: {
publishToClawHub?: boolean;
publishToNpm?: boolean;
};
};
};
expect(packageJson.openclaw?.install).toEqual(plugin.install);
expect(packageJson.openclaw?.release).toEqual({
publishToClawHub: true,
publishToNpm: true,
});
}
});
});
describe("collectClawHubVersionGateErrors", () => {
it("requires a version bump when a publishable plugin changes", () => {
const repoDir = createTempPluginRepo();
const baseRef = git(repoDir, ["rev-parse", "HEAD"]);
writeFileSync(
join(repoDir, "extensions", "demo-plugin", "index.ts"),
"export const demo = 2;\n",
);
git(repoDir, ["add", "."]);
git(repoDir, [
"-c",
"user.name=Test",
"-c",
"user.email=test@example.com",
"commit",
"-m",
"change plugin",
]);
const headRef = git(repoDir, ["rev-parse", "HEAD"]);
const errors = collectClawHubVersionGateErrors({
rootDir: repoDir,
plugins: collectClawHubPublishablePluginPackages(repoDir),
gitRange: { baseRef, headRef },
});
expect(errors).toEqual([
"@openclaw/demo-plugin@2026.4.1: changed publishable plugin still has the same version in package.json.",
]);
});
it("does not require a version bump for the first ClawHub opt-in", () => {
const repoDir = createTempPluginRepo({
publishToClawHub: false,
});
const baseRef = git(repoDir, ["rev-parse", "HEAD"]);
writeFileSync(
join(repoDir, "extensions", "demo-plugin", "package.json"),
JSON.stringify(
{
name: "@openclaw/demo-plugin",
version: "2026.4.1",
type: "module",
repository: {
type: "git",
url: OPENCLAW_PLUGIN_NPM_REPOSITORY_URL,
},
openclaw: {
extensions: ["./index.ts"],
compat: {
pluginApi: ">=2026.4.1",
},
install: {
npmSpec: "@openclaw/demo-plugin",
},
build: {
openclawVersion: "2026.4.1",
},
release: {
publishToClawHub: true,
},
},
},
null,
2,
),
);
git(repoDir, ["add", "."]);
git(repoDir, [
"-c",
"user.name=Test",
"-c",
"user.email=test@example.com",
"commit",
"-m",
"opt in",
]);
const headRef = git(repoDir, ["rev-parse", "HEAD"]);
const errors = collectClawHubVersionGateErrors({
rootDir: repoDir,
plugins: collectClawHubPublishablePluginPackages(repoDir),
gitRange: { baseRef, headRef },
});
expect(errors).toStrictEqual([]);
});
it("does not require a version bump for shared release-tooling changes", () => {
const repoDir = createTempPluginRepo();
const { baseRef, headRef } = commitSharedReleaseToolingChange(repoDir);
const errors = collectClawHubVersionGateErrors({
rootDir: repoDir,
plugins: collectClawHubPublishablePluginPackages(repoDir),
gitRange: { baseRef, headRef },
});
expect(errors).toStrictEqual([]);
});
});
describe("resolveSelectedClawHubPublishablePluginPackages", () => {
it("selects all publishable plugins when shared release tooling changes", () => {
const repoDir = createTempPluginRepo({
extraExtensionIds: ["demo-two"],
});
const { baseRef, headRef } = commitSharedReleaseToolingChange(repoDir);
const selected = resolveSelectedClawHubPublishablePluginPackages({
rootDir: repoDir,
plugins: collectClawHubPublishablePluginPackages(repoDir),
gitRange: { baseRef, headRef },
});
expect(selected.map((plugin) => plugin.extensionId)).toEqual(["demo-plugin", "demo-two"]);
});
it("selects all publishable plugins when the shared setup action changes", () => {
const repoDir = createTempPluginRepo({
extraExtensionIds: ["demo-two"],
});
const baseRef = git(repoDir, ["rev-parse", "HEAD"]);
mkdirSync(join(repoDir, ".github", "actions", "setup-node-env"), { recursive: true });
writeFileSync(
join(repoDir, ".github", "actions", "setup-node-env", "action.yml"),
"name: setup-node-env\n",
);
git(repoDir, ["add", "."]);
git(repoDir, [
"-c",
"user.name=Test",
"-c",
"user.email=test@example.com",
"commit",
"-m",
"shared helpers",
]);
const headRef = git(repoDir, ["rev-parse", "HEAD"]);
const selected = resolveSelectedClawHubPublishablePluginPackages({
rootDir: repoDir,
plugins: collectClawHubPublishablePluginPackages(repoDir),
gitRange: { baseRef, headRef },
});
expect(selected.map((plugin) => plugin.extensionId)).toEqual(["demo-plugin", "demo-two"]);
});
});
describe("collectPluginClawHubReleasePlan", () => {
it("keeps existing trusted packages with missing versions as normal candidates", async () => {
const repoDir = createTempPluginRepo();
const { fetchImpl, requests } = createClawHubPlanFetch({
packages: {
"@openclaw/demo-plugin": {
status: 200,
body: {
package: {},
owner: {},
},
},
},
trustedPublishers: {
"@openclaw/demo-plugin": {
status: 200,
body: {
trustedPublisher: {
repository: "openclaw/openclaw",
workflowFilename: "plugin-clawhub-release.yml",
},
},
},
},
versions: {
"@openclaw/demo-plugin@2026.4.1": 404,
},
});
const plan = await collectPluginClawHubReleasePlan({
rootDir: repoDir,
selection: ["@openclaw/demo-plugin"],
fetchImpl,
registryBaseUrl: "https://clawhub.ai",
});
expect(plan.candidates.map((plugin) => plugin.packageName)).toEqual(["@openclaw/demo-plugin"]);
expect(plan.bootstrapCandidates).toStrictEqual([]);
expect(plan.missingTrustedPublisher).toStrictEqual([]);
expect(requests).toEqual([
"/api/v1/packages/%40openclaw%2Fdemo-plugin",
"/api/v1/packages/%40openclaw%2Fdemo-plugin/trusted-publisher",
"/api/v1/packages/%40openclaw%2Fdemo-plugin/versions/2026.4.1",
]);
});
it("routes missing package rows to bootstrap candidates instead of normal candidates", async () => {
const repoDir = createTempPluginRepo();
const { fetchImpl } = createClawHubPlanFetch({
packages: {
"@openclaw/demo-plugin": {
status: 404,
},
},
});
const plan = await collectPluginClawHubReleasePlan({
rootDir: repoDir,
selection: ["@openclaw/demo-plugin"],
fetchImpl,
registryBaseUrl: "https://clawhub.ai",
});
expect(plan.candidates).toStrictEqual([]);
expect(plan.bootstrapCandidates.map((plugin) => plugin.packageName)).toEqual([
"@openclaw/demo-plugin",
]);
expect(plan.bootstrapCandidates[0]).toMatchObject({
alreadyPublished: false,
artifactName: "clawhub-package-openclaw-demo-plugin-2026.4.1",
packageName: "@openclaw/demo-plugin",
version: "2026.4.1",
});
expect(plan.missingTrustedPublisher).toStrictEqual([]);
});
it("routes existing packages without trusted publisher config out of normal candidates", async () => {
const repoDir = createTempPluginRepo();
const { fetchImpl } = createClawHubPlanFetch({
packages: {
"@openclaw/demo-plugin": {
status: 200,
body: {
package: {},
owner: {},
},
},
},
trustedPublishers: {
"@openclaw/demo-plugin": {
status: 200,
body: {
trustedPublisher: null,
},
},
},
versions: {
"@openclaw/demo-plugin@2026.4.1": 404,
},
});
const plan = await collectPluginClawHubReleasePlan({
rootDir: repoDir,
selection: ["@openclaw/demo-plugin"],
fetchImpl,
registryBaseUrl: "https://clawhub.ai",
});
expect(plan.candidates).toStrictEqual([]);
expect(plan.bootstrapCandidates).toStrictEqual([]);
expect(plan.missingTrustedPublisher.map((plugin) => plugin.packageName)).toEqual([
"@openclaw/demo-plugin",
]);
expect(plan.missingTrustedPublisher[0]).toMatchObject({
alreadyPublished: false,
artifactName: "clawhub-package-openclaw-demo-plugin-2026.4.1",
packageName: "@openclaw/demo-plugin",
version: "2026.4.1",
});
});
it("routes environment-pinned trusted publisher config out of normal candidates", async () => {
const repoDir = createTempPluginRepo();
const { fetchImpl } = createClawHubPlanFetch({
packages: {
"@openclaw/demo-plugin": {
status: 200,
body: {
package: {},
owner: {},
},
},
},
trustedPublishers: {
"@openclaw/demo-plugin": {
status: 200,
body: {
trustedPublisher: {
repository: "openclaw/openclaw",
workflowFilename: "plugin-clawhub-release.yml",
environment: "clawhub-plugin-release",
},
},
},
},
versions: {
"@openclaw/demo-plugin@2026.4.1": 404,
},
});
const plan = await collectPluginClawHubReleasePlan({
rootDir: repoDir,
selection: ["@openclaw/demo-plugin"],
fetchImpl,
registryBaseUrl: "https://clawhub.ai",
});
expect(plan.candidates).toStrictEqual([]);
expect(plan.bootstrapCandidates).toStrictEqual([]);
expect(plan.missingTrustedPublisher.map((plugin) => plugin.packageName)).toEqual([
"@openclaw/demo-plugin",
]);
});
it("skips versions that already exist on ClawHub", async () => {
const repoDir = createTempPluginRepo();
const { fetchImpl } = createClawHubPlanFetch({
packages: {
"@openclaw/demo-plugin": {
status: 200,
body: {
package: {},
owner: {},
},
},
},
trustedPublishers: {
"@openclaw/demo-plugin": {
status: 200,
body: {
trustedPublisher: null,
},
},
},
versions: {
"@openclaw/demo-plugin@2026.4.1": 200,
},
});
const plan = await collectPluginClawHubReleasePlan({
rootDir: repoDir,
selection: ["@openclaw/demo-plugin"],
fetchImpl,
registryBaseUrl: "https://clawhub.ai",
});
expect(plan.candidates).toStrictEqual([]);
expect(plan.bootstrapCandidates).toStrictEqual([]);
expect(plan.missingTrustedPublisher.map((plugin) => plugin.packageName)).toEqual([
"@openclaw/demo-plugin",
]);
expect(plan.missingTrustedPublisher[0]).toMatchObject({
alreadyPublished: true,
artifactName: "clawhub-package-openclaw-demo-plugin-2026.4.1",
packageName: "@openclaw/demo-plugin",
version: "2026.4.1",
});
expect(plan.skippedPublished).toHaveLength(1);
expect(plan.skippedPublished[0]).toEqual({
alreadyPublished: true,
artifactName: "clawhub-package-openclaw-demo-plugin-2026.4.1",
channel: "stable",
extensionId: "demo-plugin",
packageDir: "extensions/demo-plugin",
packageName: "@openclaw/demo-plugin",
publishTag: "latest",
version: "2026.4.1",
});
});
it("plans selected packages without validating unrelated publishable packages", async () => {
const repoDir = createTempPluginRepo({
extraExtensionIds: ["broken-plugin"],
});
writeFileSync(
join(repoDir, "extensions", "broken-plugin", "package.json"),
JSON.stringify(
{
name: "@openclaw/broken-plugin",
version: "2026.4.1",
openclaw: {
extensions: ["./index.ts"],
release: {
publishToClawHub: true,
},
},
},
null,
2,
),
);
const plan = await collectPluginClawHubReleasePlan({
rootDir: repoDir,
selection: ["@openclaw/demo-plugin"],
fetchImpl: createClawHubPlanFetch({
packages: {
"@openclaw/demo-plugin": {
status: 200,
body: {
package: {},
owner: {},
},
},
},
trustedPublishers: {
"@openclaw/demo-plugin": {
status: 200,
body: {
trustedPublisher: {
repository: "openclaw/openclaw",
workflowFilename: "plugin-clawhub-release.yml",
},
},
},
},
versions: {
"@openclaw/demo-plugin@2026.4.1": 404,
},
}).fetchImpl,
registryBaseUrl: "https://clawhub.ai",
});
expect(plan.candidates.map((plugin) => plugin.packageName)).toEqual(["@openclaw/demo-plugin"]);
expect(plan.candidates.map((plugin) => plugin.artifactName)).toEqual([
"clawhub-package-openclaw-demo-plugin-2026.4.1",
]);
});
});
describe("buildOpenClawReleaseClawHubPlan", () => {
it("emits a dispatch plan that keeps ClawHub children on the release tag", async () => {
const repoDir = createTempPluginRepo({
extraExtensionIds: ["demo-two", "demo-three"],
});
const { fetchImpl } = createClawHubPlanFetch({
packages: {
"@openclaw/demo-plugin": {
status: 200,
body: {
package: {},
owner: {},
},
},
"@openclaw/demo-two": {
status: 404,
},
"@openclaw/demo-three": {
status: 200,
body: {
package: {},
owner: {},
},
},
},
trustedPublishers: {
"@openclaw/demo-plugin": {
status: 200,
body: {
trustedPublisher: {
repository: "openclaw/openclaw",
workflowFilename: "plugin-clawhub-release.yml",
},
},
},
"@openclaw/demo-three": {
status: 200,
body: {
trustedPublisher: null,
},
},
},
versions: {
"@openclaw/demo-plugin@2026.4.1": 404,
"@openclaw/demo-three@2026.4.1": 404,
},
});
const plan = await buildOpenClawReleaseClawHubPlan(
{
releaseTag: "v2026.4.1-beta.1",
releasePublishBranch: "main",
releasePublishRunId: "12345",
pluginPublishScope: "all-publishable",
plugins: [],
},
{
rootDir: repoDir,
fetchImpl,
registryBaseUrl: "https://clawhub.ai",
},
);
expect(plan.clawHubWorkflowRef).toBe("v2026.4.1-beta.1");
expect(plan.releasePublishBranch).toBe("main");
expect(plan.normal).toEqual({
workflow: "plugin-clawhub-release.yml",
ref: "v2026.4.1-beta.1",
shouldDispatch: true,
packages: ["@openclaw/demo-plugin"],
inputs: {
publish_scope: "selected",
plugins: "@openclaw/demo-plugin",
release_publish_run_id: "12345",
release_publish_branch: "main",
},
});
expect(plan.bootstrap).toEqual({
workflow: "plugin-clawhub-new.yml",
ref: "v2026.4.1-beta.1",
shouldDispatch: true,
packages: ["@openclaw/demo-two", "@openclaw/demo-three"],
inputs: {
plugins: "@openclaw/demo-two,@openclaw/demo-three",
release_publish_run_id: "12345",
release_publish_branch: "main",
},
});
expect(new Set([...plan.normal.packages, ...plan.bootstrap.packages]).size).toBe(3);
expect(plan.summary).toEqual({
normalCount: 1,
bootstrapCount: 2,
missingTrustedPublisherCount: 1,
normalPlugins: "@openclaw/demo-plugin",
bootstrapPlugins: "@openclaw/demo-two,@openclaw/demo-three",
missingTrustedPlugins: "@openclaw/demo-three",
});
expect(plan.verifier).toEqual({
clawHubWorkflowRef: "v2026.4.1-beta.1",
});
});
it("routes already-published packages missing trusted publisher config to bootstrap repair", async () => {
const repoDir = createTempPluginRepo();
const { fetchImpl } = createClawHubPlanFetch({
packages: {
"@openclaw/demo-plugin": {
status: 200,
body: {
package: {},
owner: {},
},
},
},
trustedPublishers: {
"@openclaw/demo-plugin": {
status: 200,
body: {
trustedPublisher: null,
},
},
},
versions: {
"@openclaw/demo-plugin@2026.4.1": 200,
},
});
const plan = await buildOpenClawReleaseClawHubPlan(
{
releaseTag: "v2026.4.1-beta.1",
releasePublishBranch: "release/2026.4.1",
releasePublishRunId: "12345",
pluginPublishScope: "selected",
plugins: ["@openclaw/demo-plugin"],
},
{
rootDir: repoDir,
fetchImpl,
registryBaseUrl: "https://clawhub.ai",
},
);
expect(plan.normal.shouldDispatch).toBe(false);
expect(plan.bootstrap).toMatchObject({
workflow: "plugin-clawhub-new.yml",
ref: "v2026.4.1-beta.1",
shouldDispatch: true,
packages: ["@openclaw/demo-plugin"],
inputs: {
plugins: "@openclaw/demo-plugin",
release_publish_run_id: "12345",
release_publish_branch: "release/2026.4.1",
},
});
expect(plan.summary).toMatchObject({
normalCount: 0,
bootstrapCount: 1,
missingTrustedPublisherCount: 1,
bootstrapPlugins: "@openclaw/demo-plugin",
missingTrustedPlugins: "@openclaw/demo-plugin",
});
});
it("rejects incompatible all-publishable plugin selection args", () => {
expect(() =>
parseOpenClawReleaseClawHubPlanArgs([
"--release-tag",
"v2026.4.1-beta.1",
"--release-publish-branch",
"main",
"--release-publish-run-id",
"12345",
"--plugin-publish-scope",
"all-publishable",
"--plugins",
"@openclaw/demo-plugin",
]),
).toThrow("plugin-publish-scope=all-publishable must not be combined with --plugins.");
});
});
describe("buildOpenClawReleaseClawHubRuntimeState", () => {
it("includes the normal ClawHub run in verifier args when the release waits for it", () => {
const state = buildOpenClawReleaseClawHubRuntimeState({
repository: "openclaw/openclaw",
waitForClawHub: true,
forceSkipClawHub: false,
normalRunId: "111",
bootstrapRunId: "",
bootstrapCompleted: false,
});
expect(state.verifierArgs).toEqual(["--plugin-clawhub-run", "111"]);
expect(state.proofLines.normal).toBe(
"- plugin ClawHub publish: https://github.com/openclaw/openclaw/actions/runs/111",
);
expect(state.proofLines.bootstrap).toBe("- plugin ClawHub bootstrap: not needed");
});
it("includes a completed bootstrap run even when there is no normal ClawHub run", () => {
const state = buildOpenClawReleaseClawHubRuntimeState({
repository: "openclaw/openclaw",
waitForClawHub: false,
forceSkipClawHub: false,
normalRunId: "",
bootstrapRunId: "222",
bootstrapCompleted: true,
});
expect(state.verifierArgs).toEqual(["--plugin-clawhub-bootstrap-run", "222"]);
expect(state.proofLines.normal).toBe("- plugin ClawHub publish: no normal OIDC candidates");
expect(state.proofLines.bootstrap).toBe(
"- plugin ClawHub bootstrap: https://github.com/openclaw/openclaw/actions/runs/222",
);
});
it("skips ClawHub verification for non-awaited incomplete runs while keeping proof links", () => {
const state = buildOpenClawReleaseClawHubRuntimeState({
repository: "openclaw/openclaw",
waitForClawHub: false,
forceSkipClawHub: false,
normalRunId: "111",
bootstrapRunId: "222",
bootstrapCompleted: false,
});
expect(state.verifierArgs).toEqual(["--skip-clawhub"]);
expect(state.proofLines.normal).toBe(
"- plugin ClawHub publish: dispatched separately, not awaited by this proof: https://github.com/openclaw/openclaw/actions/runs/111",
);
expect(state.proofLines.bootstrap).toBe(
"- plugin ClawHub bootstrap: dispatched separately, not awaited by this proof: https://github.com/openclaw/openclaw/actions/runs/222",
);
});
it("keeps completed bootstrap run evidence when the normal ClawHub run is not awaited", () => {
const state = buildOpenClawReleaseClawHubRuntimeState({
repository: "openclaw/openclaw",
waitForClawHub: false,
forceSkipClawHub: false,
normalRunId: "111",
bootstrapRunId: "222",
bootstrapCompleted: true,
});
expect(state.verifierArgs).toEqual(["--skip-clawhub", "--plugin-clawhub-bootstrap-run", "222"]);
expect(state.proofLines.normal).toBe(
"- plugin ClawHub publish: dispatched separately, not awaited by this proof: https://github.com/openclaw/openclaw/actions/runs/111",
);
expect(state.proofLines.bootstrap).toBe(
"- plugin ClawHub bootstrap: https://github.com/openclaw/openclaw/actions/runs/222",
);
});
it("forces skip-clawhub after a failed child run even if ClawHub runs completed", () => {
const state = buildOpenClawReleaseClawHubRuntimeState({
repository: "openclaw/openclaw",
waitForClawHub: true,
forceSkipClawHub: true,
normalRunId: "111",
bootstrapRunId: "222",
bootstrapCompleted: true,
});
expect(state.verifierArgs).toEqual(["--skip-clawhub"]);
expect(state.proofLines.normal).toBe(
"- plugin ClawHub publish: https://github.com/openclaw/openclaw/actions/runs/111",
);
expect(state.proofLines.bootstrap).toBe(
"- plugin ClawHub bootstrap: https://github.com/openclaw/openclaw/actions/runs/222",
);
});
});
describe("plugin-clawhub-publish.sh", () => {
it("previews the publish command through the ClawHub CLI dry-run preflight", () => {
const repoDir = createTempPluginRepo();
const binDir = join(repoDir, "bin");
const markerPath = join(repoDir, "clawhub-invoked");
mkdirSync(binDir, { recursive: true });
const clawhubPath = join(binDir, "clawhub");
writeFileSync(
clawhubPath,
`#!/usr/bin/env bash
set -euo pipefail
printf '%s\\n' "$*" >> ${JSON.stringify(markerPath)}
if [[ "\${1:-}" == "--workdir" ]]; then
shift 2
fi
if [[ "\${1:-}" == "package" && "\${2:-}" == "pack" ]]; then
pack_destination=""
while [[ "$#" -gt 0 ]]; do
case "$1" in
--pack-destination)
pack_destination="\${2:-}"
shift 2
;;
*)
shift
;;
esac
done
mkdir -p "$pack_destination"
pack_path="$pack_destination/openclaw-demo-plugin-2026.4.1.tgz"
printf 'fake tgz\\n' > "$pack_path"
printf '{"path":"%s","name":"@openclaw/demo-plugin","version":"2026.4.1"}\\n' "$pack_path"
fi
exit 0
`,
);
chmodSync(clawhubPath, 0o755);
const output = execFileSync(
"bash",
[
join(process.cwd(), "scripts/plugin-clawhub-publish.sh"),
"--dry-run",
"extensions/demo-plugin",
],
{
cwd: repoDir,
encoding: "utf8",
env: {
...process.env,
PATH: `${binDir}${delimiter}${process.env.PATH ?? ""}`,
},
},
);
expect(output).toContain("Publish command: CLAWHUB_WORKDIR=");
expect(output).toContain("Resolved ClawPack:");
const invocations = readFileSync(markerPath, "utf8");
const resolvedRepoDir = realpathSync(repoDir);
expect(invocations).toContain(`--workdir ${resolvedRepoDir}`);
expect(invocations).toContain(
`package pack ${join(resolvedRepoDir, "extensions/demo-plugin")}`,
);
expect(invocations).toContain("package publish ");
expect(invocations).toContain(".tgz --tags latest");
expect(invocations).toContain("--dry-run");
});
it("passes a manual override reason when trusted publisher repair requires one", () => {
const repoDir = createTempPluginRepo();
const binDir = join(repoDir, "bin");
const markerPath = join(repoDir, "clawhub-invoked");
mkdirSync(binDir, { recursive: true });
const clawhubPath = join(binDir, "clawhub");
writeFileSync(
clawhubPath,
`#!/usr/bin/env bash
set -euo pipefail
printf '%s\\n' "$*" >> ${JSON.stringify(markerPath)}
if [[ "\${1:-}" == "--workdir" ]]; then
shift 2
fi
if [[ "\${1:-}" == "package" && "\${2:-}" == "pack" ]]; then
pack_destination=""
while [[ "$#" -gt 0 ]]; do
case "$1" in
--pack-destination)
pack_destination="\${2:-}"
shift 2
;;
*)
shift
;;
esac
done
mkdir -p "$pack_destination"
pack_path="$pack_destination/openclaw-demo-plugin-2026.4.1.tgz"
printf 'fake tgz\\n' > "$pack_path"
printf '{"path":"%s","name":"@openclaw/demo-plugin","version":"2026.4.1"}\\n' "$pack_path"
fi
exit 0
`,
);
chmodSync(clawhubPath, 0o755);
execFileSync(
"bash",
[
join(process.cwd(), "scripts/plugin-clawhub-publish.sh"),
"--publish",
"extensions/demo-plugin",
],
{
cwd: repoDir,
encoding: "utf8",
env: {
...process.env,
OPENCLAW_CLAWHUB_MANUAL_OVERRIDE_REASON:
"GitHub Actions trusted publisher repair before OIDC migration",
PATH: `${binDir}${delimiter}${process.env.PATH ?? ""}`,
},
},
);
const invocations = readFileSync(markerPath, "utf8");
expect(invocations).toContain("package publish ");
expect(invocations).toContain(
"--manual-override-reason GitHub Actions trusted publisher repair before OIDC migration",
);
});
it("packs a reusable workflow artifact without publishing", () => {
const repoDir = createTempPluginRepo();
const binDir = join(repoDir, "bin");
const markerPath = join(repoDir, "clawhub-invoked");
const outputDir = join(repoDir, "clawhub-artifacts");
mkdirSync(binDir, { recursive: true });
const clawhubPath = join(binDir, "clawhub");
writeFileSync(
clawhubPath,
`#!/usr/bin/env bash
set -euo pipefail
printf '%s\\n' "$*" >> ${JSON.stringify(markerPath)}
if [[ "\${1:-}" == "--workdir" ]]; then
shift 2
fi
if [[ "\${1:-}" == "package" && "\${2:-}" == "pack" ]]; then
pack_destination=""
while [[ "$#" -gt 0 ]]; do
case "$1" in
--pack-destination)
pack_destination="\${2:-}"
shift 2
;;
*)
shift
;;
esac
done
mkdir -p "$pack_destination"
pack_path="$pack_destination/openclaw-demo-plugin-2026.4.1.tgz"
printf 'fake tgz\\n' > "$pack_path"
printf '{"path":"%s","name":"@openclaw/demo-plugin","version":"2026.4.1"}\\n' "$pack_path"
fi
exit 0
`,
);
chmodSync(clawhubPath, 0o755);
const output = execFileSync(
"bash",
[
join(process.cwd(), "scripts/plugin-clawhub-publish.sh"),
"--pack",
"extensions/demo-plugin",
],
{
cwd: repoDir,
encoding: "utf8",
env: {
...process.env,
OPENCLAW_CLAWHUB_PACK_OUTPUT_DIR: outputDir,
PATH: `${binDir}${delimiter}${process.env.PATH ?? ""}`,
},
},
);
expect(output).toContain("Packed ClawPack:");
expect(existsSync(join(outputDir, "openclaw-demo-plugin-2026.4.1.tgz"))).toBe(true);
const invocations = readFileSync(markerPath, "utf8");
expect(invocations).toContain("package pack ");
expect(invocations).not.toContain("package publish ");
});
});
describe("collectPluginClawHubReleasePathsFromGitRange", () => {
it("rejects unsafe git refs", () => {
const repoDir = createTempPluginRepo();
const headRef = git(repoDir, ["rev-parse", "HEAD"]);
expect(() =>
collectPluginClawHubReleasePathsFromGitRange({
rootDir: repoDir,
gitRange: {
baseRef: "--not-a-ref",
headRef,
},
}),
).toThrow("baseRef must be a normal git ref or commit SHA.");
});
});
function createTempPluginRepo(
options: {
extensionId?: string;
extraExtensionIds?: string[];
publishToClawHub?: boolean;
includeClawHubContract?: boolean;
} = {},
) {
const repoDir = makeTempRepoRoot(tempDirs, "openclaw-clawhub-release-");
const extensionId = options.extensionId ?? "demo-plugin";
const extensionIds = [extensionId, ...(options.extraExtensionIds ?? [])];
writeFileSync(
join(repoDir, "package.json"),
JSON.stringify({ name: "openclaw-test-root", type: "module" }, null, 2),
);
writeFileSync(join(repoDir, "pnpm-lock.yaml"), "lockfileVersion: '9.0'\n");
for (const currentExtensionId of extensionIds) {
mkdirSync(join(repoDir, "extensions", currentExtensionId), { recursive: true });
writeFileSync(
join(repoDir, "extensions", currentExtensionId, "package.json"),
JSON.stringify(
{
name: `@openclaw/${currentExtensionId}`,
version: "2026.4.1",
type: "module",
repository: {
type: "git",
url: OPENCLAW_PLUGIN_NPM_REPOSITORY_URL,
},
openclaw: {
extensions: ["./index.ts"],
...(options.includeClawHubContract === false
? {}
: {
compat: {
pluginApi: ">=2026.4.1",
},
build: {
openclawVersion: "2026.4.1",
},
}),
install: {
npmSpec: `@openclaw/${currentExtensionId}`,
},
release: {
publishToClawHub: options.publishToClawHub ?? true,
},
},
},
null,
2,
),
);
writeFileSync(
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"]);
git(repoDir, ["add", "."]);
git(repoDir, [
"-c",
"user.name=Test",
"-c",
"user.email=test@example.com",
"commit",
"-m",
"init",
]);
return repoDir;
}
function commitSharedReleaseToolingChange(repoDir: string) {
const baseRef = git(repoDir, ["rev-parse", "HEAD"]);
mkdirSync(join(repoDir, "scripts"), { recursive: true });
writeFileSync(join(repoDir, "scripts", "plugin-clawhub-publish.sh"), "#!/usr/bin/env bash\n");
git(repoDir, ["add", "."]);
git(repoDir, [
"-c",
"user.name=Test",
"-c",
"user.email=test@example.com",
"commit",
"-m",
"shared tooling",
]);
const headRef = git(repoDir, ["rev-parse", "HEAD"]);
return { baseRef, headRef };
}
function createClawHubPlanFetch(config: {
packages: Record<
string,
{
status: number;
body?: unknown;
}
>;
trustedPublishers?: Record<
string,
{
status: number;
body?: unknown;
}
>;
versions?: Record<string, number>;
}) {
const requests: string[] = [];
const fetchImpl: typeof fetch = async (input) => {
const requestUrl =
typeof input === "string" ? input : input instanceof URL ? input.href : input.url;
const url = new URL(requestUrl);
requests.push(url.pathname);
const packageMatch = url.pathname.match(/^\/api\/v1\/packages\/([^/]+)$/u);
if (packageMatch) {
const packageName = decodeURIComponent(packageMatch[1]);
const packageResponse = config.packages[packageName];
if (!packageResponse) {
throw new Error(`Unexpected package detail request for ${packageName}`);
}
return new Response(JSON.stringify(packageResponse.body ?? {}), {
status: packageResponse.status,
});
}
const trustedPublisherMatch = url.pathname.match(
/^\/api\/v1\/packages\/([^/]+)\/trusted-publisher$/u,
);
if (trustedPublisherMatch) {
const packageName = decodeURIComponent(trustedPublisherMatch[1]);
const trustedPublisherResponse = config.trustedPublishers?.[packageName];
if (!trustedPublisherResponse) {
throw new Error(`Unexpected trusted-publisher request for ${packageName}`);
}
return new Response(JSON.stringify(trustedPublisherResponse.body ?? {}), {
status: trustedPublisherResponse.status,
});
}
const versionMatch = url.pathname.match(/^\/api\/v1\/packages\/([^/]+)\/versions\/([^/]+)$/u);
if (versionMatch) {
const packageName = decodeURIComponent(versionMatch[1]);
const version = decodeURIComponent(versionMatch[2]);
const status = config.versions?.[`${packageName}@${version}`];
if (!status) {
throw new Error(`Unexpected version detail request for ${packageName}@${version}`);
}
return new Response("{}", { status });
}
throw new Error(`Unexpected ClawHub request to ${url.pathname}`);
};
return { fetchImpl, requests };
}
function git(cwd: string, args: string[]) {
return execFileSync("git", ["-C", cwd, ...args], {
encoding: "utf8",
stdio: ["ignore", "pipe", "pipe"],
}).trim();
}