diff --git a/.github/workflows/plugin-clawhub-release.yml b/.github/workflows/plugin-clawhub-release.yml new file mode 100644 index 00000000000..a6bd638952e --- /dev/null +++ b/.github/workflows/plugin-clawhub-release.yml @@ -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}" diff --git a/extensions/bluebubbles/package.json b/extensions/bluebubbles/package.json index a88305a5799..3b5bde93014 100644 --- a/extensions/bluebubbles/package.json +++ b/extensions/bluebubbles/package.json @@ -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 } } diff --git a/extensions/diagnostics-otel/package.json b/extensions/diagnostics-otel/package.json index 977903e5bc3..b2c1c14453c 100644 --- a/extensions/diagnostics-otel/package.json +++ b/extensions/diagnostics-otel/package.json @@ -20,7 +20,14 @@ "extensions": [ "./index.ts" ], + "compat": { + "pluginApi": ">=2026.4.1" + }, + "build": { + "openclawVersion": "2026.4.1" + }, "release": { + "publishToClawHub": true, "publishToNpm": true } } diff --git a/extensions/discord/package.json b/extensions/discord/package.json index ce7f341b780..14a501d4628 100644 --- a/extensions/discord/package.json +++ b/extensions/discord/package.json @@ -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 } } diff --git a/extensions/feishu/package.json b/extensions/feishu/package.json index 2440d082190..03717988348 100644 --- a/extensions/feishu/package.json +++ b/extensions/feishu/package.json @@ -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 } } diff --git a/extensions/lobster/package.json b/extensions/lobster/package.json index 197c23ec5a1..0de888bfd7d 100644 --- a/extensions/lobster/package.json +++ b/extensions/lobster/package.json @@ -10,7 +10,14 @@ "extensions": [ "./index.ts" ], + "compat": { + "pluginApi": ">=2026.4.1" + }, + "build": { + "openclawVersion": "2026.4.1" + }, "release": { + "publishToClawHub": true, "publishToNpm": true } } diff --git a/extensions/memory-lancedb/package.json b/extensions/memory-lancedb/package.json index a2bebad9fd5..5157f2e820c 100644 --- a/extensions/memory-lancedb/package.json +++ b/extensions/memory-lancedb/package.json @@ -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 } } diff --git a/extensions/msteams/package.json b/extensions/msteams/package.json index 2f8d3540d65..9eedd10042a 100644 --- a/extensions/msteams/package.json +++ b/extensions/msteams/package.json @@ -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 } } diff --git a/extensions/nextcloud-talk/package.json b/extensions/nextcloud-talk/package.json index 8fe9cc0f251..6f2cda1d7e0 100644 --- a/extensions/nextcloud-talk/package.json +++ b/extensions/nextcloud-talk/package.json @@ -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 } } diff --git a/extensions/nostr/package.json b/extensions/nostr/package.json index 53dc27bd760..65a4f272e6f 100644 --- a/extensions/nostr/package.json +++ b/extensions/nostr/package.json @@ -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 } } diff --git a/extensions/qqbot/package.json b/extensions/qqbot/package.json index 185bf6a9e82..bbef18d7dbb 100644 --- a/extensions/qqbot/package.json +++ b/extensions/qqbot/package.json @@ -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 } } diff --git a/extensions/voice-call/package.json b/extensions/voice-call/package.json index 3806c510b50..1568ac8988d 100644 --- a/extensions/voice-call/package.json +++ b/extensions/voice-call/package.json @@ -26,7 +26,14 @@ "install": { "minHostVersion": ">=2026.4.1" }, + "compat": { + "pluginApi": ">=2026.4.1" + }, + "build": { + "openclawVersion": "2026.4.1" + }, "release": { + "publishToClawHub": true, "publishToNpm": true } } diff --git a/extensions/whatsapp/package.json b/extensions/whatsapp/package.json index c1378b117aa..74e2cfb7ffe 100644 --- a/extensions/whatsapp/package.json +++ b/extensions/whatsapp/package.json @@ -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 } } diff --git a/extensions/zalo/package.json b/extensions/zalo/package.json index 7ed2a888c8a..d0bb92e8b24 100644 --- a/extensions/zalo/package.json +++ b/extensions/zalo/package.json @@ -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 } } diff --git a/extensions/zalouser/package.json b/extensions/zalouser/package.json index 95ccbd2f14c..95b6b8cfc7c 100644 --- a/extensions/zalouser/package.json +++ b/extensions/zalouser/package.json @@ -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 } } diff --git a/package.json b/package.json index c0dd575d8ad..ab696e79bcf 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/scripts/lib/plugin-clawhub-release.ts b/scripts/lib/plugin-clawhub-release.ts new file mode 100644 index 00000000000..40c3d5fda39 --- /dev/null +++ b/scripts/lib/plugin-clawhub-release.ts @@ -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 { + 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 { + 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), + }; +} diff --git a/scripts/plugin-clawhub-publish.sh b/scripts/plugin-clawhub-publish.sh new file mode 100644 index 00000000000..cacddf0d030 --- /dev/null +++ b/scripts/plugin-clawhub-publish.sh @@ -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] " >&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:-}" +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[@]}" diff --git a/scripts/plugin-clawhub-release-check.ts b/scripts/plugin-clawhub-release-check.ts new file mode 100644 index 00000000000..6602c2f97d0 --- /dev/null +++ b/scripts/plugin-clawhub-release-check.ts @@ -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)); +} diff --git a/scripts/plugin-clawhub-release-plan.ts b/scripts/plugin-clawhub-release-plan.ts new file mode 100644 index 00000000000..e95fa5be052 --- /dev/null +++ b/scripts/plugin-clawhub-release-plan.ts @@ -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)); +} diff --git a/test/plugin-clawhub-release.test.ts b/test/plugin-clawhub-release.test.ts new file mode 100644 index 00000000000..267d26dd147 --- /dev/null +++ b/test/plugin-clawhub-release.test.ts @@ -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(); +}