CI: add ClawHub plugin release workflow (#59179)

* CI: add ClawHub plugin release workflow

* CI: harden ClawHub plugin release workflow

* CI: finish ClawHub plugin release hardening

* CI: watch shared ClawHub release inputs

* CI: harden ClawHub publish workflow

* CI: watch more ClawHub release deps

* CI: match shared release inputs by prefix

* CI: pin ClawHub publish source commit

* CI: refresh pinned ClawHub release commit

* CI: rename ClawHub plugin release environment

---------

Co-authored-by: Onur Solmaz <onur@solmaz.io>
This commit is contained in:
Onur
2026-04-03 15:40:07 +02:00
committed by GitHub
parent e0e5df25e6
commit fa9e1e3d8e
21 changed files with 1358 additions and 0 deletions

View File

@@ -0,0 +1,293 @@
name: Plugin ClawHub Release
on:
push:
branches:
- main
paths:
- ".github/workflows/plugin-clawhub-release.yml"
- ".github/actions/setup-node-env/**"
- "extensions/**"
- "package.json"
- "pnpm-lock.yaml"
- "packages/plugin-package-contract/src/index.ts"
- "scripts/lib/npm-publish-plan.mjs"
- "scripts/lib/plugin-npm-release.ts"
- "scripts/lib/plugin-clawhub-release.ts"
- "scripts/openclaw-npm-release-check.ts"
- "scripts/plugin-clawhub-publish.sh"
- "scripts/plugin-clawhub-release-check.ts"
- "scripts/plugin-clawhub-release-plan.ts"
workflow_dispatch:
inputs:
publish_scope:
description: Publish the selected plugins or all ClawHub-publishable plugins from the workflow ref
required: true
default: selected
type: choice
options:
- selected
- all-publishable
plugins:
description: Comma-separated plugin package names to publish when publish_scope=selected
required: false
type: string
concurrency:
group: plugin-clawhub-release-${{ github.sha }}
cancel-in-progress: false
env:
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true"
NODE_VERSION: "24.x"
PNPM_VERSION: "10.23.0"
CLAWHUB_REGISTRY: "https://clawhub.ai"
CLAWHUB_REPOSITORY: "openclaw/clawhub"
# Pinned to a reviewed ClawHub commit so release behavior stays reproducible.
CLAWHUB_REF: "4af2bd50a71465683dbf8aa269af764b9d39bdf5"
jobs:
preview_plugins_clawhub:
runs-on: ubuntu-latest
permissions:
contents: read
outputs:
ref_sha: ${{ steps.ref.outputs.sha }}
has_candidates: ${{ steps.plan.outputs.has_candidates }}
candidate_count: ${{ steps.plan.outputs.candidate_count }}
skipped_published_count: ${{ steps.plan.outputs.skipped_published_count }}
matrix: ${{ steps.plan.outputs.matrix }}
steps:
- name: Checkout
uses: actions/checkout@v6
with:
ref: ${{ github.sha }}
fetch-depth: 0
- name: Setup Node environment
uses: ./.github/actions/setup-node-env
with:
node-version: ${{ env.NODE_VERSION }}
pnpm-version: ${{ env.PNPM_VERSION }}
install-bun: "false"
use-sticky-disk: "false"
- name: Resolve checked-out ref
id: ref
run: echo "sha=$(git rev-parse HEAD)" >> "$GITHUB_OUTPUT"
- name: Validate ref is on main
run: |
set -euo pipefail
git fetch --no-tags origin +refs/heads/main:refs/remotes/origin/main
git merge-base --is-ancestor HEAD origin/main
- name: Validate publishable plugin metadata
env:
PUBLISH_SCOPE: ${{ github.event_name == 'workflow_dispatch' && inputs.publish_scope || '' }}
RELEASE_PLUGINS: ${{ github.event_name == 'workflow_dispatch' && inputs.plugins || '' }}
BASE_REF: ${{ github.event_name != 'workflow_dispatch' && github.event.before || '' }}
HEAD_REF: ${{ steps.ref.outputs.sha }}
run: |
set -euo pipefail
if [[ -n "${PUBLISH_SCOPE}" ]]; then
release_args=(--selection-mode "${PUBLISH_SCOPE}")
if [[ -n "${RELEASE_PLUGINS}" ]]; then
release_args+=(--plugins "${RELEASE_PLUGINS}")
fi
pnpm release:plugins:clawhub:check -- "${release_args[@]}"
elif [[ -n "${BASE_REF}" ]]; then
pnpm release:plugins:clawhub:check -- --base-ref "${BASE_REF}" --head-ref "${HEAD_REF}"
else
pnpm release:plugins:clawhub:check
fi
- name: Resolve plugin release plan
id: plan
env:
PUBLISH_SCOPE: ${{ github.event_name == 'workflow_dispatch' && inputs.publish_scope || '' }}
RELEASE_PLUGINS: ${{ github.event_name == 'workflow_dispatch' && inputs.plugins || '' }}
BASE_REF: ${{ github.event_name != 'workflow_dispatch' && github.event.before || '' }}
HEAD_REF: ${{ steps.ref.outputs.sha }}
CLAWHUB_REGISTRY: ${{ env.CLAWHUB_REGISTRY }}
run: |
set -euo pipefail
mkdir -p .local
if [[ -n "${PUBLISH_SCOPE}" ]]; then
plan_args=(--selection-mode "${PUBLISH_SCOPE}")
if [[ -n "${RELEASE_PLUGINS}" ]]; then
plan_args+=(--plugins "${RELEASE_PLUGINS}")
fi
node --import tsx scripts/plugin-clawhub-release-plan.ts "${plan_args[@]}" > .local/plugin-clawhub-release-plan.json
elif [[ -n "${BASE_REF}" ]]; then
node --import tsx scripts/plugin-clawhub-release-plan.ts --base-ref "${BASE_REF}" --head-ref "${HEAD_REF}" > .local/plugin-clawhub-release-plan.json
else
node --import tsx scripts/plugin-clawhub-release-plan.ts > .local/plugin-clawhub-release-plan.json
fi
cat .local/plugin-clawhub-release-plan.json
candidate_count="$(jq -r '.candidates | length' .local/plugin-clawhub-release-plan.json)"
skipped_published_count="$(jq -r '.skippedPublished | length' .local/plugin-clawhub-release-plan.json)"
has_candidates="false"
if [[ "${candidate_count}" != "0" ]]; then
has_candidates="true"
fi
matrix_json="$(jq -c '.candidates' .local/plugin-clawhub-release-plan.json)"
{
echo "candidate_count=${candidate_count}"
echo "skipped_published_count=${skipped_published_count}"
echo "has_candidates=${has_candidates}"
echo "matrix=${matrix_json}"
} >> "$GITHUB_OUTPUT"
echo "Plugin release candidates:"
jq -r '.candidates[]? | "- \(.packageName)@\(.version) [\(.publishTag)] from \(.packageDir)"' .local/plugin-clawhub-release-plan.json
echo "Already published / skipped:"
jq -r '.skippedPublished[]? | "- \(.packageName)@\(.version)"' .local/plugin-clawhub-release-plan.json
- name: Fail manual publish when target versions already exist
if: github.event_name == 'workflow_dispatch' && inputs.publish_scope == 'selected' && steps.plan.outputs.skipped_published_count != '0'
run: |
echo "::error::One or more selected plugin versions already exist on ClawHub. Bump the version before running a real publish."
exit 1
preview_plugin_pack:
needs: preview_plugins_clawhub
if: needs.preview_plugins_clawhub.outputs.has_candidates == 'true'
runs-on: ubuntu-latest
permissions:
contents: read
strategy:
fail-fast: false
matrix:
plugin: ${{ fromJson(needs.preview_plugins_clawhub.outputs.matrix) }}
steps:
- name: Checkout
uses: actions/checkout@v6
with:
ref: ${{ needs.preview_plugins_clawhub.outputs.ref_sha }}
fetch-depth: 1
- name: Setup Node environment
uses: ./.github/actions/setup-node-env
with:
node-version: ${{ env.NODE_VERSION }}
pnpm-version: ${{ env.PNPM_VERSION }}
install-bun: "true"
use-sticky-disk: "false"
install-deps: "false"
- name: Checkout ClawHub CLI source
uses: actions/checkout@v6
with:
repository: ${{ env.CLAWHUB_REPOSITORY }}
ref: ${{ env.CLAWHUB_REF }}
path: clawhub-source
fetch-depth: 1
- name: Install ClawHub CLI dependencies
working-directory: clawhub-source
run: bun install --frozen-lockfile
- name: Bootstrap ClawHub CLI
run: |
cat > "$RUNNER_TEMP/clawhub" <<'EOF'
#!/usr/bin/env bash
set -euo pipefail
exec bun "$GITHUB_WORKSPACE/clawhub-source/packages/clawhub/src/cli.ts" "$@"
EOF
chmod +x "$RUNNER_TEMP/clawhub"
echo "$RUNNER_TEMP" >> "$GITHUB_PATH"
- name: Preview publish command
env:
CLAWHUB_REGISTRY: ${{ env.CLAWHUB_REGISTRY }}
SOURCE_REPO: ${{ github.repository }}
SOURCE_COMMIT: ${{ needs.preview_plugins_clawhub.outputs.ref_sha }}
SOURCE_REF: ${{ github.ref }}
PACKAGE_TAG: ${{ matrix.plugin.publishTag }}
PACKAGE_DIR: ${{ matrix.plugin.packageDir }}
run: bash scripts/plugin-clawhub-publish.sh --dry-run "${PACKAGE_DIR}"
publish_plugins_clawhub:
needs: [preview_plugins_clawhub, preview_plugin_pack]
if: github.event_name == 'workflow_dispatch' && needs.preview_plugins_clawhub.outputs.has_candidates == 'true'
runs-on: ubuntu-latest
environment: clawhub-plugin-release
permissions:
contents: read
id-token: write
strategy:
fail-fast: false
matrix:
plugin: ${{ fromJson(needs.preview_plugins_clawhub.outputs.matrix) }}
steps:
- name: Checkout
uses: actions/checkout@v6
with:
ref: ${{ needs.preview_plugins_clawhub.outputs.ref_sha }}
fetch-depth: 1
- name: Setup Node environment
uses: ./.github/actions/setup-node-env
with:
node-version: ${{ env.NODE_VERSION }}
pnpm-version: ${{ env.PNPM_VERSION }}
install-bun: "true"
use-sticky-disk: "false"
install-deps: "false"
- name: Checkout ClawHub CLI source
uses: actions/checkout@v6
with:
repository: ${{ env.CLAWHUB_REPOSITORY }}
ref: ${{ env.CLAWHUB_REF }}
path: clawhub-source
fetch-depth: 1
- name: Install ClawHub CLI dependencies
working-directory: clawhub-source
run: bun install --frozen-lockfile
- name: Bootstrap ClawHub CLI
run: |
cat > "$RUNNER_TEMP/clawhub" <<'EOF'
#!/usr/bin/env bash
set -euo pipefail
exec bun "$GITHUB_WORKSPACE/clawhub-source/packages/clawhub/src/cli.ts" "$@"
EOF
chmod +x "$RUNNER_TEMP/clawhub"
echo "$RUNNER_TEMP" >> "$GITHUB_PATH"
- name: Ensure version is not already published
env:
PACKAGE_NAME: ${{ matrix.plugin.packageName }}
PACKAGE_VERSION: ${{ matrix.plugin.version }}
CLAWHUB_REGISTRY: ${{ env.CLAWHUB_REGISTRY }}
run: |
set -euo pipefail
encoded_name="$(node -e 'console.log(encodeURIComponent(process.env.PACKAGE_NAME ?? ""))')"
encoded_version="$(node -e 'console.log(encodeURIComponent(process.env.PACKAGE_VERSION ?? ""))')"
url="${CLAWHUB_REGISTRY%/}/api/v1/packages/${encoded_name}/versions/${encoded_version}"
status="$(curl --silent --show-error --output /dev/null --write-out '%{http_code}' "${url}")"
if [[ "${status}" =~ ^2 ]]; then
echo "${PACKAGE_NAME}@${PACKAGE_VERSION} is already published on ClawHub."
exit 1
fi
if [[ "${status}" != "404" ]]; then
echo "Unexpected ClawHub response (${status}) for ${PACKAGE_NAME}@${PACKAGE_VERSION}."
exit 1
fi
- name: Publish
env:
CLAWHUB_REGISTRY: ${{ env.CLAWHUB_REGISTRY }}
SOURCE_REPO: ${{ github.repository }}
SOURCE_COMMIT: ${{ needs.preview_plugins_clawhub.outputs.ref_sha }}
SOURCE_REF: ${{ github.ref }}
PACKAGE_TAG: ${{ matrix.plugin.publishTag }}
PACKAGE_DIR: ${{ matrix.plugin.packageDir }}
run: bash scripts/plugin-clawhub-publish.sh --publish "${PACKAGE_DIR}"

View File

@@ -41,7 +41,14 @@
"defaultChoice": "npm",
"minHostVersion": ">=2026.4.1"
},
"compat": {
"pluginApi": ">=2026.4.1"
},
"build": {
"openclawVersion": "2026.4.1"
},
"release": {
"publishToClawHub": true,
"publishToNpm": true
}
}

View File

@@ -20,7 +20,14 @@
"extensions": [
"./index.ts"
],
"compat": {
"pluginApi": ">=2026.4.1"
},
"build": {
"openclawVersion": "2026.4.1"
},
"release": {
"publishToClawHub": true,
"publishToNpm": true
}
}

View File

@@ -42,10 +42,17 @@
"defaultChoice": "npm",
"minHostVersion": ">=2026.4.1"
},
"compat": {
"pluginApi": ">=2026.4.1"
},
"build": {
"openclawVersion": "2026.4.1"
},
"bundle": {
"stageRuntimeDependencies": true
},
"release": {
"publishToClawHub": true,
"publishToNpm": true
}
}

View File

@@ -42,10 +42,17 @@
"defaultChoice": "npm",
"minHostVersion": ">=2026.4.1"
},
"compat": {
"pluginApi": ">=2026.4.1"
},
"build": {
"openclawVersion": "2026.4.1"
},
"bundle": {
"stageRuntimeDependencies": true
},
"release": {
"publishToClawHub": true,
"publishToNpm": true
}
}

View File

@@ -10,7 +10,14 @@
"extensions": [
"./index.ts"
],
"compat": {
"pluginApi": ">=2026.4.1"
},
"build": {
"openclawVersion": "2026.4.1"
},
"release": {
"publishToClawHub": true,
"publishToNpm": true
}
}

View File

@@ -17,7 +17,14 @@
"defaultChoice": "npm",
"minHostVersion": ">=2026.4.1"
},
"compat": {
"pluginApi": ">=2026.4.1"
},
"build": {
"openclawVersion": "2026.4.1"
},
"release": {
"publishToClawHub": true,
"publishToNpm": true
}
}

View File

@@ -41,7 +41,14 @@
"defaultChoice": "npm",
"minHostVersion": ">=2026.4.1"
},
"compat": {
"pluginApi": ">=2026.4.1"
},
"build": {
"openclawVersion": "2026.4.1"
},
"release": {
"publishToClawHub": true,
"publishToNpm": true
}
}

View File

@@ -38,7 +38,14 @@
"defaultChoice": "npm",
"minHostVersion": ">=2026.4.1"
},
"compat": {
"pluginApi": ">=2026.4.1"
},
"build": {
"openclawVersion": "2026.4.1"
},
"release": {
"publishToClawHub": true,
"publishToNpm": true
}
}

View File

@@ -37,7 +37,14 @@
"defaultChoice": "npm",
"minHostVersion": ">=2026.4.1"
},
"compat": {
"pluginApi": ">=2026.4.1"
},
"build": {
"openclawVersion": "2026.4.1"
},
"release": {
"publishToClawHub": true,
"publishToNpm": true
}
}

View File

@@ -42,10 +42,17 @@
"defaultChoice": "npm",
"minHostVersion": ">=2026.4.1"
},
"compat": {
"pluginApi": ">=2026.4.1"
},
"build": {
"openclawVersion": "2026.4.1"
},
"bundle": {
"stageRuntimeDependencies": true
},
"release": {
"publishToClawHub": true,
"publishToNpm": true
}
}

View File

@@ -26,7 +26,14 @@
"install": {
"minHostVersion": ">=2026.4.1"
},
"compat": {
"pluginApi": ">=2026.4.1"
},
"build": {
"openclawVersion": "2026.4.1"
},
"release": {
"publishToClawHub": true,
"publishToNpm": true
}
}

View File

@@ -38,7 +38,14 @@
"defaultChoice": "npm",
"minHostVersion": ">=2026.4.1"
},
"compat": {
"pluginApi": ">=2026.4.1"
},
"build": {
"openclawVersion": "2026.4.1"
},
"release": {
"publishToClawHub": true,
"publishToNpm": true
}
}

View File

@@ -40,7 +40,14 @@
"defaultChoice": "npm",
"minHostVersion": ">=2026.4.1"
},
"compat": {
"pluginApi": ">=2026.4.1"
},
"build": {
"openclawVersion": "2026.4.1"
},
"release": {
"publishToClawHub": true,
"publishToNpm": true
}
}

View File

@@ -41,7 +41,14 @@
"defaultChoice": "npm",
"minHostVersion": ">=2026.4.1"
},
"compat": {
"pluginApi": ">=2026.4.1"
},
"build": {
"openclawVersion": "2026.4.1"
},
"release": {
"publishToClawHub": true,
"publishToNpm": true
}
}

View File

@@ -1039,6 +1039,8 @@
"release:check": "pnpm check:base-config-schema && pnpm check:bundled-channel-config-metadata && pnpm check:bundled-provider-auth-env-vars && pnpm config:docs:check && pnpm plugin-sdk:check-exports && pnpm plugin-sdk:facades:check && pnpm plugin-sdk:api:check && node --import tsx scripts/release-check.ts",
"release:openclaw:npm:check": "node --import tsx scripts/openclaw-npm-release-check.ts",
"release:openclaw:npm:verify-published": "node --import tsx scripts/openclaw-npm-postpublish-verify.ts",
"release:plugins:clawhub:check": "node --import tsx scripts/plugin-clawhub-release-check.ts",
"release:plugins:clawhub:plan": "node --import tsx scripts/plugin-clawhub-release-plan.ts",
"release:plugins:npm:check": "node --import tsx scripts/plugin-npm-release-check.ts",
"release:plugins:npm:plan": "node --import tsx scripts/plugin-npm-release-plan.ts",
"runtime-sidecars:check": "node --import tsx scripts/generate-runtime-sidecar-paths-baseline.ts --check",

View File

@@ -0,0 +1,443 @@
import { execFileSync } from "node:child_process";
import { readdirSync, readFileSync } from "node:fs";
import { join, resolve } from "node:path";
import { validateExternalCodePluginPackageJson } from "../../packages/plugin-package-contract/src/index.ts";
import { parseReleaseVersion } from "../openclaw-npm-release-check.ts";
import {
collectChangedExtensionIdsFromPaths,
collectPublishablePluginPackageErrors,
parsePluginReleaseArgs,
parsePluginReleaseSelection,
parsePluginReleaseSelectionMode,
resolveChangedPublishablePluginPackages,
resolveSelectedPublishablePluginPackages,
type GitRangeSelection,
type ParsedPluginReleaseArgs,
type PluginReleaseSelectionMode,
} from "./plugin-npm-release.ts";
export {
collectChangedExtensionIdsFromPaths,
parsePluginReleaseArgs,
parsePluginReleaseSelection,
parsePluginReleaseSelectionMode,
resolveChangedPublishablePluginPackages,
resolveSelectedPublishablePluginPackages,
type GitRangeSelection,
type ParsedPluginReleaseArgs,
type PluginReleaseSelectionMode,
};
export type PluginPackageJson = {
name?: string;
version?: string;
private?: boolean;
openclaw?: {
extensions?: string[];
install?: {
npmSpec?: string;
};
compat?: {
pluginApi?: string;
minGatewayVersion?: string;
};
build?: {
openclawVersion?: string;
pluginSdkVersion?: string;
};
release?: {
publishToClawHub?: boolean;
publishToNpm?: boolean;
};
};
};
export type PublishablePluginPackage = {
extensionId: string;
packageDir: string;
packageName: string;
version: string;
channel: "stable" | "beta";
publishTag: "latest" | "beta";
};
export type PluginReleasePlanItem = PublishablePluginPackage & {
alreadyPublished: boolean;
};
export type PluginReleasePlan = {
all: PluginReleasePlanItem[];
candidates: PluginReleasePlanItem[];
skippedPublished: PluginReleasePlanItem[];
};
const CLAWHUB_DEFAULT_REGISTRY = "https://clawhub.ai";
const SAFE_EXTENSION_ID_RE = /^[a-z0-9][a-z0-9._-]*$/;
const CLAWHUB_SHARED_RELEASE_INPUT_PATHS = [
".github/workflows/plugin-clawhub-release.yml",
".github/actions/setup-node-env",
"package.json",
"pnpm-lock.yaml",
"packages/plugin-package-contract/src/index.ts",
"scripts/lib/npm-publish-plan.mjs",
"scripts/lib/plugin-npm-release.ts",
"scripts/lib/plugin-clawhub-release.ts",
"scripts/openclaw-npm-release-check.ts",
"scripts/plugin-clawhub-publish.sh",
"scripts/plugin-clawhub-release-check.ts",
"scripts/plugin-clawhub-release-plan.ts",
] as const;
function readPluginPackageJson(path: string): PluginPackageJson {
return JSON.parse(readFileSync(path, "utf8")) as PluginPackageJson;
}
function normalizePath(path: string) {
return path.trim().replaceAll("\\", "/");
}
function isNullGitRef(ref: string | undefined): boolean {
return !ref || /^0+$/.test(ref);
}
function assertSafeGitRef(ref: string, label: string) {
const trimmed = ref.trim();
if (!trimmed || isNullGitRef(trimmed)) {
throw new Error(`${label} is required.`);
}
if (
trimmed.startsWith("-") ||
trimmed.includes("\u0000") ||
trimmed.includes("\r") ||
trimmed.includes("\n")
) {
throw new Error(`${label} must be a normal git ref or commit SHA.`);
}
return trimmed;
}
function resolveGitCommitSha(rootDir: string, ref: string, label: string) {
const safeRef = assertSafeGitRef(ref, label);
try {
return execFileSync("git", ["rev-parse", "--verify", "--quiet", `${safeRef}^{commit}`], {
cwd: rootDir,
encoding: "utf8",
stdio: ["ignore", "pipe", "pipe"],
}).trim();
} catch {
throw new Error(`${label} is not a valid git commit ref: ${safeRef}`);
}
}
function getRegistryBaseUrl(explicit?: string) {
return (
explicit?.trim() ||
process.env.CLAWHUB_REGISTRY?.trim() ||
process.env.CLAWHUB_SITE?.trim() ||
CLAWHUB_DEFAULT_REGISTRY
);
}
export function collectClawHubPublishablePluginPackages(
rootDir = resolve("."),
): PublishablePluginPackage[] {
const extensionsDir = join(rootDir, "extensions");
const dirs = readdirSync(extensionsDir, { withFileTypes: true }).filter((entry) =>
entry.isDirectory(),
);
const publishable: PublishablePluginPackage[] = [];
const validationErrors: string[] = [];
for (const dir of dirs) {
const packageDir = join("extensions", dir.name);
const absolutePackageDir = join(extensionsDir, dir.name);
const packageJsonPath = join(absolutePackageDir, "package.json");
let packageJson: PluginPackageJson;
try {
packageJson = readPluginPackageJson(packageJsonPath);
} catch {
continue;
}
if (packageJson.openclaw?.release?.publishToClawHub !== true) {
continue;
}
if (!SAFE_EXTENSION_ID_RE.test(dir.name)) {
validationErrors.push(
`${dir.name}: extension directory name must match ^[a-z0-9][a-z0-9._-]*$ for ClawHub publish.`,
);
continue;
}
const errors = collectPublishablePluginPackageErrors({
extensionId: dir.name,
packageDir,
packageJson,
});
if (errors.length > 0) {
validationErrors.push(...errors.map((error) => `${dir.name}: ${error}`));
continue;
}
const contractValidation = validateExternalCodePluginPackageJson(packageJson);
if (contractValidation.issues.length > 0) {
validationErrors.push(
...contractValidation.issues.map((issue) => `${dir.name}: ${issue.message}`),
);
continue;
}
const version = packageJson.version!.trim();
const parsedVersion = parseReleaseVersion(version);
if (parsedVersion === null) {
validationErrors.push(
`${dir.name}: package.json version must match YYYY.M.D, YYYY.M.D-N, or YYYY.M.D-beta.N; found "${version}".`,
);
continue;
}
publishable.push({
extensionId: dir.name,
packageDir,
packageName: packageJson.name!.trim(),
version,
channel: parsedVersion.channel,
publishTag: parsedVersion.channel === "beta" ? "beta" : "latest",
});
}
if (validationErrors.length > 0) {
throw new Error(
`Publishable ClawHub plugin metadata validation failed:\n${validationErrors.map((error) => `- ${error}`).join("\n")}`,
);
}
return publishable.toSorted((left, right) => left.packageName.localeCompare(right.packageName));
}
export function collectPluginClawHubReleasePathsFromGitRange(params: {
rootDir?: string;
gitRange: GitRangeSelection;
}): string[] {
return collectPluginClawHubReleasePathsFromGitRangeForPathspecs(params, ["extensions"]);
}
function collectPluginClawHubRelevantPathsFromGitRange(params: {
rootDir?: string;
gitRange: GitRangeSelection;
}): string[] {
return collectPluginClawHubReleasePathsFromGitRangeForPathspecs(params, [
"extensions",
...CLAWHUB_SHARED_RELEASE_INPUT_PATHS,
]);
}
function collectPluginClawHubReleasePathsFromGitRangeForPathspecs(
params: {
rootDir?: string;
gitRange: GitRangeSelection;
},
pathspecs: readonly string[],
): string[] {
const rootDir = params.rootDir ?? resolve(".");
const { baseRef, headRef } = params.gitRange;
if (isNullGitRef(baseRef) || isNullGitRef(headRef)) {
return [];
}
const baseSha = resolveGitCommitSha(rootDir, baseRef, "baseRef");
const headSha = resolveGitCommitSha(rootDir, headRef, "headRef");
return execFileSync(
"git",
["diff", "--name-only", "--diff-filter=ACMR", baseSha, headSha, "--", ...pathspecs],
{
cwd: rootDir,
encoding: "utf8",
stdio: ["ignore", "pipe", "pipe"],
},
)
.split("\n")
.map((line) => line.trim())
.filter(Boolean)
.map((path) => normalizePath(path));
}
function hasSharedClawHubReleaseInputChanges(changedPaths: readonly string[]) {
return changedPaths.some((path) =>
CLAWHUB_SHARED_RELEASE_INPUT_PATHS.some(
(sharedPath) => path === sharedPath || path.startsWith(`${sharedPath}/`),
),
);
}
export function resolveChangedClawHubPublishablePluginPackages(params: {
plugins: PublishablePluginPackage[];
changedPaths: readonly string[];
}): PublishablePluginPackage[] {
return resolveChangedPublishablePluginPackages({
plugins: params.plugins,
changedExtensionIds: collectChangedExtensionIdsFromPaths(params.changedPaths),
});
}
export function resolveSelectedClawHubPublishablePluginPackages(params: {
plugins: PublishablePluginPackage[];
selection?: string[];
selectionMode?: PluginReleaseSelectionMode;
gitRange?: GitRangeSelection;
rootDir?: string;
}): PublishablePluginPackage[] {
if (params.selectionMode === "all-publishable") {
return params.plugins;
}
if (params.selection && params.selection.length > 0) {
return resolveSelectedPublishablePluginPackages({
plugins: params.plugins,
selection: params.selection,
});
}
if (params.gitRange) {
const changedPaths = collectPluginClawHubRelevantPathsFromGitRange({
rootDir: params.rootDir,
gitRange: params.gitRange,
});
if (hasSharedClawHubReleaseInputChanges(changedPaths)) {
return params.plugins;
}
return resolveChangedClawHubPublishablePluginPackages({
plugins: params.plugins,
changedPaths,
});
}
return params.plugins;
}
function readPackageManifestAtGitRef(params: {
rootDir?: string;
ref: string;
packageDir: string;
}): PluginPackageJson | null {
const rootDir = params.rootDir ?? resolve(".");
const commitSha = resolveGitCommitSha(rootDir, params.ref, "ref");
try {
const raw = execFileSync("git", ["show", `${commitSha}:${params.packageDir}/package.json`], {
cwd: rootDir,
encoding: "utf8",
stdio: ["ignore", "pipe", "pipe"],
});
return JSON.parse(raw) as PluginPackageJson;
} catch {
return null;
}
}
export function collectClawHubVersionGateErrors(params: {
plugins: PublishablePluginPackage[];
gitRange: GitRangeSelection;
rootDir?: string;
}): string[] {
const changedPaths = collectPluginClawHubReleasePathsFromGitRange({
rootDir: params.rootDir,
gitRange: params.gitRange,
});
const changedPlugins = resolveChangedClawHubPublishablePluginPackages({
plugins: params.plugins,
changedPaths,
});
const errors: string[] = [];
for (const plugin of changedPlugins) {
const baseManifest = readPackageManifestAtGitRef({
rootDir: params.rootDir,
ref: params.gitRange.baseRef,
packageDir: plugin.packageDir,
});
if (baseManifest?.openclaw?.release?.publishToClawHub !== true) {
continue;
}
const baseVersion =
typeof baseManifest.version === "string" && baseManifest.version.trim()
? baseManifest.version.trim()
: null;
if (baseVersion === null || baseVersion !== plugin.version) {
continue;
}
errors.push(
`${plugin.packageName}@${plugin.version}: changed publishable plugin still has the same version in package.json.`,
);
}
return errors;
}
export async function isPluginVersionPublishedOnClawHub(
packageName: string,
version: string,
options: {
fetchImpl?: typeof fetch;
registryBaseUrl?: string;
} = {},
): Promise<boolean> {
const fetchImpl = options.fetchImpl ?? fetch;
const url = new URL(
`/api/v1/packages/${encodeURIComponent(packageName)}/versions/${encodeURIComponent(version)}`,
getRegistryBaseUrl(options.registryBaseUrl),
);
const response = await fetchImpl(url, {
method: "GET",
headers: {
Accept: "application/json",
},
});
if (response.status === 404) {
return false;
}
if (response.ok) {
return true;
}
throw new Error(
`Failed to query ClawHub for ${packageName}@${version}: ${response.status} ${response.statusText}`,
);
}
export async function collectPluginClawHubReleasePlan(params?: {
rootDir?: string;
selection?: string[];
selectionMode?: PluginReleaseSelectionMode;
gitRange?: GitRangeSelection;
registryBaseUrl?: string;
fetchImpl?: typeof fetch;
}): Promise<PluginReleasePlan> {
const allPublishable = collectClawHubPublishablePluginPackages(params?.rootDir);
const selectedPublishable = resolveSelectedClawHubPublishablePluginPackages({
plugins: allPublishable,
selection: params?.selection,
selectionMode: params?.selectionMode,
gitRange: params?.gitRange,
rootDir: params?.rootDir,
});
const all = await Promise.all(
selectedPublishable.map(async (plugin) => ({
...plugin,
alreadyPublished: await isPluginVersionPublishedOnClawHub(
plugin.packageName,
plugin.version,
{
registryBaseUrl: params?.registryBaseUrl,
fetchImpl: params?.fetchImpl,
},
),
})),
);
return {
all,
candidates: all.filter((plugin) => !plugin.alreadyPublished),
skippedPublished: all.filter((plugin) => plugin.alreadyPublished),
};
}

View File

@@ -0,0 +1,88 @@
#!/usr/bin/env bash
set -euo pipefail
mode="${1:-}"
package_dir="${2:-}"
if [[ "${mode}" != "--dry-run" && "${mode}" != "--publish" ]]; then
echo "usage: bash scripts/plugin-clawhub-publish.sh [--dry-run|--publish] <package-dir>" >&2
exit 2
fi
if [[ -z "${package_dir}" ]]; then
echo "missing package dir" >&2
exit 2
fi
if [[ ! "${package_dir}" =~ ^extensions/[a-z0-9][a-z0-9._-]*$ ]]; then
echo "invalid package dir: ${package_dir}" >&2
exit 2
fi
if [[ ! -f "${package_dir}/package.json" ]]; then
echo "package.json not found under ${package_dir}" >&2
exit 2
fi
if ! command -v clawhub >/dev/null 2>&1; then
echo "clawhub CLI is required on PATH" >&2
exit 1
fi
package_name="$(node -e 'const pkg = require(require("node:path").resolve(process.argv[1], "package.json")); console.log(pkg.name)' "${package_dir}")"
package_version="$(node -e 'const pkg = require(require("node:path").resolve(process.argv[1], "package.json")); console.log(pkg.version)' "${package_dir}")"
publish_tag="${PACKAGE_TAG:-latest}"
source_repo="${SOURCE_REPO:-${GITHUB_REPOSITORY:-openclaw/openclaw}}"
source_commit="${SOURCE_COMMIT:-$(git rev-parse HEAD)}"
source_ref="${SOURCE_REF:-$(git symbolic-ref -q HEAD || true)}"
clawhub_workdir="${CLAWDHUB_WORKDIR:-${CLAWHUB_WORKDIR:-$(pwd)}}"
publish_source="${package_dir}"
if [[ "${publish_source}" != /* && "${publish_source}" != ./* ]]; then
publish_source="./${publish_source}"
fi
publish_cmd=(
clawhub
package
publish
"${publish_source}"
--tags
"${publish_tag}"
--source-repo
"${source_repo}"
--source-commit
"${source_commit}"
--source-path
"${package_dir}"
)
if [[ -n "${source_ref}" ]]; then
publish_cmd+=(
--source-ref
"${source_ref}"
)
fi
echo "Resolved package dir: ${package_dir}"
echo "Resolved publish source: ${publish_source}"
echo "Resolved package name: ${package_name}"
echo "Resolved package version: ${package_version}"
echo "Resolved publish tag: ${publish_tag}"
echo "Resolved source repo: ${source_repo}"
echo "Resolved source commit: ${source_commit}"
echo "Resolved source ref: ${source_ref:-<missing>}"
echo "Resolved ClawHub workdir: ${clawhub_workdir}"
echo "Publish auth: GitHub Actions OIDC via ClawHub short-lived token"
printf 'Publish command:'
printf ' %q' "${publish_cmd[@]}"
printf '\n'
if [[ "${mode}" == "--dry-run" ]]; then
CLAWHUB_WORKDIR="${clawhub_workdir}" "${publish_cmd[@]}" --dry-run
exit 0
fi
CLAWHUB_WORKDIR="${clawhub_workdir}" "${publish_cmd[@]}"

View File

@@ -0,0 +1,51 @@
#!/usr/bin/env -S node --import tsx
import { pathToFileURL } from "node:url";
import {
collectClawHubPublishablePluginPackages,
collectClawHubVersionGateErrors,
parsePluginReleaseArgs,
resolveSelectedClawHubPublishablePluginPackages,
} from "./lib/plugin-clawhub-release.ts";
export async function runPluginClawHubReleaseCheck(argv: string[]) {
const { selection, selectionMode, baseRef, headRef } = parsePluginReleaseArgs(argv);
const publishable = collectClawHubPublishablePluginPackages();
const gitRange = baseRef && headRef ? { baseRef, headRef } : undefined;
const selected = resolveSelectedClawHubPublishablePluginPackages({
plugins: publishable,
selection,
selectionMode,
gitRange,
});
if (gitRange) {
const errors = collectClawHubVersionGateErrors({
plugins: publishable,
gitRange,
});
if (errors.length > 0) {
throw new Error(
`plugin-clawhub-release-check: version bumps required before ClawHub publish:\n${errors
.map((error) => ` - ${error}`)
.join("\n")}`,
);
}
}
console.log("plugin-clawhub-release-check: publishable plugin metadata looks OK.");
if (gitRange && selected.length === 0) {
console.log(
` - no publishable plugin package changes detected between ${gitRange.baseRef} and ${gitRange.headRef}`,
);
}
for (const plugin of selected) {
console.log(
` - ${plugin.packageName}@${plugin.version} (${plugin.channel}, ${plugin.extensionId})`,
);
}
}
if (import.meta.url === pathToFileURL(process.argv[1] ?? "").href) {
await runPluginClawHubReleaseCheck(process.argv.slice(2));
}

View File

@@ -0,0 +1,21 @@
#!/usr/bin/env -S node --import tsx
import { pathToFileURL } from "node:url";
import {
collectPluginClawHubReleasePlan,
parsePluginReleaseArgs,
} from "./lib/plugin-clawhub-release.ts";
export async function collectPluginReleasePlanForClawHub(argv: string[]) {
const { selection, selectionMode, baseRef, headRef } = parsePluginReleaseArgs(argv);
return await collectPluginClawHubReleasePlan({
selection,
selectionMode,
gitRange: baseRef && headRef ? { baseRef, headRef } : undefined,
});
}
if (import.meta.url === pathToFileURL(process.argv[1] ?? "").href) {
const plan = await collectPluginReleasePlanForClawHub(process.argv.slice(2));
console.log(JSON.stringify(plan, null, 2));
}

View File

@@ -0,0 +1,362 @@
import { execFileSync } from "node:child_process";
import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import {
collectClawHubPublishablePluginPackages,
collectClawHubVersionGateErrors,
collectPluginClawHubReleasePathsFromGitRange,
collectPluginClawHubReleasePlan,
resolveChangedClawHubPublishablePluginPackages,
resolveSelectedClawHubPublishablePluginPackages,
type PublishablePluginPackage,
} from "../scripts/lib/plugin-clawhub-release.ts";
const tempDirs: string[] = [];
afterEach(() => {
while (tempDirs.length > 0) {
const dir = tempDirs.pop();
if (dir) {
rmSync(dir, { recursive: true, force: true });
}
}
});
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"],
}),
).toEqual([]);
});
});
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 plugins published to ClawHub.",
);
});
it("rejects unsafe extension directory names", () => {
const repoDir = createTempPluginRepo({
extensionId: "Demo Plugin",
});
expect(() => collectClawHubPublishablePluginPackages(repoDir)).toThrow(
"Demo Plugin: extension directory name must match",
);
});
});
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",
openclaw: {
extensions: ["./index.ts"],
compat: {
pluginApi: ">=2026.4.1",
},
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).toEqual([]);
});
it("does not require a version bump for shared release-tooling changes", () => {
const repoDir = createTempPluginRepo();
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"]);
const errors = collectClawHubVersionGateErrors({
rootDir: repoDir,
plugins: collectClawHubPublishablePluginPackages(repoDir),
gitRange: { baseRef, headRef },
});
expect(errors).toEqual([]);
});
});
describe("resolveSelectedClawHubPublishablePluginPackages", () => {
it("selects all publishable plugins when shared release tooling changes", () => {
const repoDir = createTempPluginRepo({
extraExtensionIds: ["demo-two"],
});
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"]);
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("skips versions that already exist on ClawHub", async () => {
const repoDir = createTempPluginRepo();
const plan = await collectPluginClawHubReleasePlan({
rootDir: repoDir,
selection: ["@openclaw/demo-plugin"],
fetchImpl: async () => new Response("{}", { status: 200 }),
registryBaseUrl: "https://clawhub.ai",
});
expect(plan.candidates).toEqual([]);
expect(plan.skippedPublished).toHaveLength(1);
expect(plan.skippedPublished[0]).toMatchObject({
packageName: "@openclaw/demo-plugin",
version: "2026.4.1",
});
});
});
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 = mkdtempSync(join(tmpdir(), "openclaw-clawhub-release-"));
tempDirs.push(repoDir);
const extensionId = options.extensionId ?? "demo-plugin";
const extensionIds = [extensionId, ...(options.extraExtensionIds ?? [])];
writeFileSync(
join(repoDir, "package.json"),
JSON.stringify({ name: "openclaw-test-root" }, 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",
openclaw: {
extensions: ["./index.ts"],
...(options.includeClawHubContract === false
? {}
: {
compat: {
pluginApi: ">=2026.4.1",
},
build: {
openclawVersion: "2026.4.1",
},
}),
release: {
publishToClawHub: options.publishToClawHub ?? true,
},
},
},
null,
2,
),
);
writeFileSync(
join(repoDir, "extensions", currentExtensionId, "index.ts"),
`export const ${currentExtensionId.replaceAll(/[-.]/g, "_")} = 1;\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 git(cwd: string, args: string[]) {
return execFileSync("git", ["-C", cwd, ...args], {
encoding: "utf8",
stdio: ["ignore", "pipe", "pipe"],
}).trim();
}