mirror of
https://github.com/openclaw/openclaw.git
synced 2026-04-10 16:51:13 +00:00
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:
293
.github/workflows/plugin-clawhub-release.yml
vendored
Normal file
293
.github/workflows/plugin-clawhub-release.yml
vendored
Normal 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}"
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20,7 +20,14 @@
|
||||
"extensions": [
|
||||
"./index.ts"
|
||||
],
|
||||
"compat": {
|
||||
"pluginApi": ">=2026.4.1"
|
||||
},
|
||||
"build": {
|
||||
"openclawVersion": "2026.4.1"
|
||||
},
|
||||
"release": {
|
||||
"publishToClawHub": true,
|
||||
"publishToNpm": true
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,7 +10,14 @@
|
||||
"extensions": [
|
||||
"./index.ts"
|
||||
],
|
||||
"compat": {
|
||||
"pluginApi": ">=2026.4.1"
|
||||
},
|
||||
"build": {
|
||||
"openclawVersion": "2026.4.1"
|
||||
},
|
||||
"release": {
|
||||
"publishToClawHub": true,
|
||||
"publishToNpm": true
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -26,7 +26,14 @@
|
||||
"install": {
|
||||
"minHostVersion": ">=2026.4.1"
|
||||
},
|
||||
"compat": {
|
||||
"pluginApi": ">=2026.4.1"
|
||||
},
|
||||
"build": {
|
||||
"openclawVersion": "2026.4.1"
|
||||
},
|
||||
"release": {
|
||||
"publishToClawHub": true,
|
||||
"publishToNpm": true
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
443
scripts/lib/plugin-clawhub-release.ts
Normal file
443
scripts/lib/plugin-clawhub-release.ts
Normal 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),
|
||||
};
|
||||
}
|
||||
88
scripts/plugin-clawhub-publish.sh
Normal file
88
scripts/plugin-clawhub-publish.sh
Normal 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[@]}"
|
||||
51
scripts/plugin-clawhub-release-check.ts
Normal file
51
scripts/plugin-clawhub-release-check.ts
Normal 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));
|
||||
}
|
||||
21
scripts/plugin-clawhub-release-plan.ts
Normal file
21
scripts/plugin-clawhub-release-plan.ts
Normal 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));
|
||||
}
|
||||
362
test/plugin-clawhub-release.test.ts
Normal file
362
test/plugin-clawhub-release.test.ts
Normal 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();
|
||||
}
|
||||
Reference in New Issue
Block a user