Release: add plugin npm publish workflow (#47678)
* Release: add plugin npm publish workflow * Release: make plugin publish scope explicit
This commit is contained in:
parent
089a43f5e8
commit
d41c9ad4cb
214
.github/workflows/plugin-npm-release.yml
vendored
Normal file
214
.github/workflows/plugin-npm-release.yml
vendored
Normal file
@ -0,0 +1,214 @@
|
|||||||
|
name: Plugin NPM Release
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- main
|
||||||
|
paths:
|
||||||
|
- ".github/workflows/plugin-npm-release.yml"
|
||||||
|
- "extensions/**"
|
||||||
|
- "package.json"
|
||||||
|
- "scripts/lib/plugin-npm-release.ts"
|
||||||
|
- "scripts/plugin-npm-publish.sh"
|
||||||
|
- "scripts/plugin-npm-release-check.ts"
|
||||||
|
- "scripts/plugin-npm-release-plan.ts"
|
||||||
|
workflow_dispatch:
|
||||||
|
inputs:
|
||||||
|
publish_scope:
|
||||||
|
description: Publish the selected plugins or all publishable plugins from the ref
|
||||||
|
required: true
|
||||||
|
default: selected
|
||||||
|
type: choice
|
||||||
|
options:
|
||||||
|
- selected
|
||||||
|
- all-publishable
|
||||||
|
ref:
|
||||||
|
description: Commit SHA on main to publish from (copy from the preview run)
|
||||||
|
required: true
|
||||||
|
type: string
|
||||||
|
plugins:
|
||||||
|
description: Comma-separated plugin package names to publish when publish_scope=selected
|
||||||
|
required: false
|
||||||
|
type: string
|
||||||
|
|
||||||
|
concurrency:
|
||||||
|
group: plugin-npm-release-${{ github.event_name == 'workflow_dispatch' && inputs.ref || github.sha }}
|
||||||
|
cancel-in-progress: false
|
||||||
|
|
||||||
|
env:
|
||||||
|
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true"
|
||||||
|
NODE_VERSION: "24.x"
|
||||||
|
PNPM_VERSION: "10.23.0"
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
preview_plugins_npm:
|
||||||
|
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 }}
|
||||||
|
matrix: ${{ steps.plan.outputs.matrix }}
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v6
|
||||||
|
with:
|
||||||
|
ref: ${{ github.event_name == 'workflow_dispatch' && inputs.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:npm:check -- "${release_args[@]}"
|
||||||
|
elif [[ -n "${BASE_REF}" ]]; then
|
||||||
|
pnpm release:plugins:npm:check -- --base-ref "${BASE_REF}" --head-ref "${HEAD_REF}"
|
||||||
|
else
|
||||||
|
pnpm release:plugins:npm: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 }}
|
||||||
|
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-npm-release-plan.ts "${plan_args[@]}" > .local/plugin-npm-release-plan.json
|
||||||
|
elif [[ -n "${BASE_REF}" ]]; then
|
||||||
|
node --import tsx scripts/plugin-npm-release-plan.ts --base-ref "${BASE_REF}" --head-ref "${HEAD_REF}" > .local/plugin-npm-release-plan.json
|
||||||
|
else
|
||||||
|
node --import tsx scripts/plugin-npm-release-plan.ts > .local/plugin-npm-release-plan.json
|
||||||
|
fi
|
||||||
|
|
||||||
|
cat .local/plugin-npm-release-plan.json
|
||||||
|
|
||||||
|
candidate_count="$(jq -r '.candidates | length' .local/plugin-npm-release-plan.json)"
|
||||||
|
has_candidates="false"
|
||||||
|
if [[ "${candidate_count}" != "0" ]]; then
|
||||||
|
has_candidates="true"
|
||||||
|
fi
|
||||||
|
matrix_json="$(jq -c '.candidates' .local/plugin-npm-release-plan.json)"
|
||||||
|
|
||||||
|
{
|
||||||
|
echo "candidate_count=${candidate_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-npm-release-plan.json
|
||||||
|
|
||||||
|
echo "Already published / skipped:"
|
||||||
|
jq -r '.skippedPublished[]? | "- \(.packageName)@\(.version)"' .local/plugin-npm-release-plan.json
|
||||||
|
|
||||||
|
preview_plugin_pack:
|
||||||
|
needs: preview_plugins_npm
|
||||||
|
if: needs.preview_plugins_npm.outputs.has_candidates == 'true'
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
strategy:
|
||||||
|
fail-fast: false
|
||||||
|
matrix:
|
||||||
|
plugin: ${{ fromJson(needs.preview_plugins_npm.outputs.matrix) }}
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v6
|
||||||
|
with:
|
||||||
|
ref: ${{ needs.preview_plugins_npm.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: "false"
|
||||||
|
use-sticky-disk: "false"
|
||||||
|
install-deps: "false"
|
||||||
|
|
||||||
|
- name: Preview publish command
|
||||||
|
run: bash scripts/plugin-npm-publish.sh --dry-run "${{ matrix.plugin.packageDir }}"
|
||||||
|
|
||||||
|
- name: Preview npm pack contents
|
||||||
|
working-directory: ${{ matrix.plugin.packageDir }}
|
||||||
|
run: npm pack --dry-run --json --ignore-scripts
|
||||||
|
|
||||||
|
publish_plugins_npm:
|
||||||
|
needs: [preview_plugins_npm, preview_plugin_pack]
|
||||||
|
if: github.event_name == 'workflow_dispatch' && needs.preview_plugins_npm.outputs.has_candidates == 'true'
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
environment: npm-release
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
id-token: write
|
||||||
|
strategy:
|
||||||
|
fail-fast: false
|
||||||
|
matrix:
|
||||||
|
plugin: ${{ fromJson(needs.preview_plugins_npm.outputs.matrix) }}
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v6
|
||||||
|
with:
|
||||||
|
ref: ${{ needs.preview_plugins_npm.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: "false"
|
||||||
|
use-sticky-disk: "false"
|
||||||
|
install-deps: "false"
|
||||||
|
|
||||||
|
- name: Ensure version is not already published
|
||||||
|
env:
|
||||||
|
PACKAGE_NAME: ${{ matrix.plugin.packageName }}
|
||||||
|
PACKAGE_VERSION: ${{ matrix.plugin.version }}
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
if npm view "${PACKAGE_NAME}@${PACKAGE_VERSION}" version >/dev/null 2>&1; then
|
||||||
|
echo "${PACKAGE_NAME}@${PACKAGE_VERSION} is already published on npm."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
- name: Publish
|
||||||
|
run: bash scripts/plugin-npm-publish.sh --publish "${{ matrix.plugin.packageDir }}"
|
||||||
@ -32,6 +32,9 @@
|
|||||||
"npmSpec": "@openclaw/bluebubbles",
|
"npmSpec": "@openclaw/bluebubbles",
|
||||||
"localPath": "extensions/bluebubbles",
|
"localPath": "extensions/bluebubbles",
|
||||||
"defaultChoice": "npm"
|
"defaultChoice": "npm"
|
||||||
|
},
|
||||||
|
"release": {
|
||||||
|
"publishToNpm": true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -19,6 +19,9 @@
|
|||||||
"openclaw": {
|
"openclaw": {
|
||||||
"extensions": [
|
"extensions": [
|
||||||
"./index.ts"
|
"./index.ts"
|
||||||
]
|
],
|
||||||
|
"release": {
|
||||||
|
"publishToNpm": true
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -7,6 +7,9 @@
|
|||||||
"extensions": [
|
"extensions": [
|
||||||
"./index.ts"
|
"./index.ts"
|
||||||
],
|
],
|
||||||
"setupEntry": "./setup-entry.ts"
|
"setupEntry": "./setup-entry.ts",
|
||||||
|
"release": {
|
||||||
|
"publishToNpm": true
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -31,6 +31,9 @@
|
|||||||
"npmSpec": "@openclaw/feishu",
|
"npmSpec": "@openclaw/feishu",
|
||||||
"localPath": "extensions/feishu",
|
"localPath": "extensions/feishu",
|
||||||
"defaultChoice": "npm"
|
"defaultChoice": "npm"
|
||||||
|
},
|
||||||
|
"release": {
|
||||||
|
"publishToNpm": true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -9,6 +9,9 @@
|
|||||||
"openclaw": {
|
"openclaw": {
|
||||||
"extensions": [
|
"extensions": [
|
||||||
"./index.ts"
|
"./index.ts"
|
||||||
]
|
],
|
||||||
|
"release": {
|
||||||
|
"publishToNpm": true
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -31,6 +31,9 @@
|
|||||||
"localPath": "extensions/matrix",
|
"localPath": "extensions/matrix",
|
||||||
"defaultChoice": "npm"
|
"defaultChoice": "npm"
|
||||||
},
|
},
|
||||||
|
"release": {
|
||||||
|
"publishToNpm": true
|
||||||
|
},
|
||||||
"releaseChecks": {
|
"releaseChecks": {
|
||||||
"rootDependencyMirrorAllowlist": [
|
"rootDependencyMirrorAllowlist": [
|
||||||
"@matrix-org/matrix-sdk-crypto-nodejs",
|
"@matrix-org/matrix-sdk-crypto-nodejs",
|
||||||
|
|||||||
@ -29,6 +29,9 @@
|
|||||||
"localPath": "extensions/msteams",
|
"localPath": "extensions/msteams",
|
||||||
"defaultChoice": "npm"
|
"defaultChoice": "npm"
|
||||||
},
|
},
|
||||||
|
"release": {
|
||||||
|
"publishToNpm": true
|
||||||
|
},
|
||||||
"releaseChecks": {
|
"releaseChecks": {
|
||||||
"rootDependencyMirrorAllowlist": [
|
"rootDependencyMirrorAllowlist": [
|
||||||
"@microsoft/agents-hosting"
|
"@microsoft/agents-hosting"
|
||||||
|
|||||||
@ -29,6 +29,9 @@
|
|||||||
"npmSpec": "@openclaw/nextcloud-talk",
|
"npmSpec": "@openclaw/nextcloud-talk",
|
||||||
"localPath": "extensions/nextcloud-talk",
|
"localPath": "extensions/nextcloud-talk",
|
||||||
"defaultChoice": "npm"
|
"defaultChoice": "npm"
|
||||||
|
},
|
||||||
|
"release": {
|
||||||
|
"publishToNpm": true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -27,6 +27,9 @@
|
|||||||
"localPath": "extensions/nostr",
|
"localPath": "extensions/nostr",
|
||||||
"defaultChoice": "npm"
|
"defaultChoice": "npm"
|
||||||
},
|
},
|
||||||
|
"release": {
|
||||||
|
"publishToNpm": true
|
||||||
|
},
|
||||||
"releaseChecks": {
|
"releaseChecks": {
|
||||||
"rootDependencyMirrorAllowlist": [
|
"rootDependencyMirrorAllowlist": [
|
||||||
"nostr-tools"
|
"nostr-tools"
|
||||||
|
|||||||
@ -12,6 +12,9 @@
|
|||||||
"openclaw": {
|
"openclaw": {
|
||||||
"extensions": [
|
"extensions": [
|
||||||
"./index.ts"
|
"./index.ts"
|
||||||
]
|
],
|
||||||
|
"release": {
|
||||||
|
"publishToNpm": true
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -29,6 +29,9 @@
|
|||||||
"npmSpec": "@openclaw/zalo",
|
"npmSpec": "@openclaw/zalo",
|
||||||
"localPath": "extensions/zalo",
|
"localPath": "extensions/zalo",
|
||||||
"defaultChoice": "npm"
|
"defaultChoice": "npm"
|
||||||
|
},
|
||||||
|
"release": {
|
||||||
|
"publishToNpm": true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -31,6 +31,9 @@
|
|||||||
"localPath": "extensions/zalouser",
|
"localPath": "extensions/zalouser",
|
||||||
"defaultChoice": "npm"
|
"defaultChoice": "npm"
|
||||||
},
|
},
|
||||||
|
"release": {
|
||||||
|
"publishToNpm": true
|
||||||
|
},
|
||||||
"releaseChecks": {
|
"releaseChecks": {
|
||||||
"rootDependencyMirrorAllowlist": [
|
"rootDependencyMirrorAllowlist": [
|
||||||
"zca-js"
|
"zca-js"
|
||||||
|
|||||||
@ -593,6 +593,8 @@
|
|||||||
"protocol:gen:swift": "node --import tsx scripts/protocol-gen-swift.ts",
|
"protocol:gen:swift": "node --import tsx scripts/protocol-gen-swift.ts",
|
||||||
"release:check": "pnpm config:docs:check && node --import tsx scripts/release-check.ts",
|
"release:check": "pnpm config:docs:check && node --import tsx scripts/release-check.ts",
|
||||||
"release:openclaw:npm:check": "node --import tsx scripts/openclaw-npm-release-check.ts",
|
"release:openclaw:npm:check": "node --import tsx scripts/openclaw-npm-release-check.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",
|
||||||
"start": "node scripts/run-node.mjs",
|
"start": "node scripts/run-node.mjs",
|
||||||
"test": "node scripts/test-parallel.mjs",
|
"test": "node scripts/test-parallel.mjs",
|
||||||
"test:all": "pnpm lint && pnpm build && pnpm test && pnpm test:e2e && pnpm test:live && pnpm test:docker:all",
|
"test:all": "pnpm lint && pnpm build && pnpm test && pnpm test:e2e && pnpm test:live && pnpm test:docker:all",
|
||||||
|
|||||||
394
scripts/lib/plugin-npm-release.ts
Normal file
394
scripts/lib/plugin-npm-release.ts
Normal file
@ -0,0 +1,394 @@
|
|||||||
|
import { execFileSync } from "node:child_process";
|
||||||
|
import { mkdtempSync, readdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
||||||
|
import { tmpdir } from "node:os";
|
||||||
|
import { join, resolve } from "node:path";
|
||||||
|
import { parseReleaseVersion } from "../openclaw-npm-release-check.ts";
|
||||||
|
|
||||||
|
export type PluginPackageJson = {
|
||||||
|
name?: string;
|
||||||
|
version?: string;
|
||||||
|
private?: boolean;
|
||||||
|
openclaw?: {
|
||||||
|
extensions?: string[];
|
||||||
|
install?: {
|
||||||
|
npmSpec?: string;
|
||||||
|
};
|
||||||
|
release?: {
|
||||||
|
publishToNpm?: boolean;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export type PublishablePluginPackage = {
|
||||||
|
extensionId: string;
|
||||||
|
packageDir: string;
|
||||||
|
packageName: string;
|
||||||
|
version: string;
|
||||||
|
channel: "stable" | "beta";
|
||||||
|
publishTag: "latest" | "beta";
|
||||||
|
installNpmSpec?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type PluginReleasePlanItem = PublishablePluginPackage & {
|
||||||
|
alreadyPublished: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type PluginReleasePlan = {
|
||||||
|
all: PluginReleasePlanItem[];
|
||||||
|
candidates: PluginReleasePlanItem[];
|
||||||
|
skippedPublished: PluginReleasePlanItem[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export type PluginReleaseSelectionMode = "selected" | "all-publishable";
|
||||||
|
|
||||||
|
export type GitRangeSelection = {
|
||||||
|
baseRef: string;
|
||||||
|
headRef: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ParsedPluginReleaseArgs = {
|
||||||
|
selection: string[];
|
||||||
|
selectionMode?: PluginReleaseSelectionMode;
|
||||||
|
pluginsFlagProvided: boolean;
|
||||||
|
baseRef?: string;
|
||||||
|
headRef?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type PublishablePluginPackageCandidate = {
|
||||||
|
extensionId: string;
|
||||||
|
packageDir: string;
|
||||||
|
packageJson: PluginPackageJson;
|
||||||
|
};
|
||||||
|
|
||||||
|
function readPluginPackageJson(path: string): PluginPackageJson {
|
||||||
|
return JSON.parse(readFileSync(path, "utf8")) as PluginPackageJson;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parsePluginReleaseSelection(value: string | undefined): string[] {
|
||||||
|
if (!value?.trim()) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
...new Set(
|
||||||
|
value
|
||||||
|
.split(/[,\s]+/)
|
||||||
|
.map((item) => item.trim())
|
||||||
|
.filter(Boolean),
|
||||||
|
),
|
||||||
|
].toSorted();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parsePluginReleaseSelectionMode(
|
||||||
|
value: string | undefined,
|
||||||
|
): PluginReleaseSelectionMode {
|
||||||
|
if (value === "selected" || value === "all-publishable") {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error(
|
||||||
|
`Unknown selection mode: ${value ?? "<missing>"}. Expected "selected" or "all-publishable".`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parsePluginReleaseArgs(argv: string[]): ParsedPluginReleaseArgs {
|
||||||
|
let selection: string[] = [];
|
||||||
|
let selectionMode: PluginReleaseSelectionMode | undefined;
|
||||||
|
let pluginsFlagProvided = false;
|
||||||
|
let baseRef: string | undefined;
|
||||||
|
let headRef: string | undefined;
|
||||||
|
|
||||||
|
for (let index = 0; index < argv.length; index += 1) {
|
||||||
|
const arg = argv[index];
|
||||||
|
if (arg === "--") {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (arg === "--plugins") {
|
||||||
|
selection = parsePluginReleaseSelection(argv[index + 1]);
|
||||||
|
pluginsFlagProvided = true;
|
||||||
|
index += 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (arg === "--selection-mode") {
|
||||||
|
selectionMode = parsePluginReleaseSelectionMode(argv[index + 1]);
|
||||||
|
index += 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (arg === "--base-ref") {
|
||||||
|
baseRef = argv[index + 1];
|
||||||
|
index += 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (arg === "--head-ref") {
|
||||||
|
headRef = argv[index + 1];
|
||||||
|
index += 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
throw new Error(`Unknown argument: ${arg}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (pluginsFlagProvided && selection.length === 0) {
|
||||||
|
throw new Error("`--plugins` must include at least one package name.");
|
||||||
|
}
|
||||||
|
if (selectionMode === "selected" && !pluginsFlagProvided) {
|
||||||
|
throw new Error("`--selection-mode selected` requires `--plugins`.");
|
||||||
|
}
|
||||||
|
if (selectionMode === "all-publishable" && pluginsFlagProvided) {
|
||||||
|
throw new Error("`--selection-mode all-publishable` must not be combined with `--plugins`.");
|
||||||
|
}
|
||||||
|
if (selection.length > 0 && (baseRef || headRef)) {
|
||||||
|
throw new Error("Use either --plugins or --base-ref/--head-ref, not both.");
|
||||||
|
}
|
||||||
|
if (selectionMode && (baseRef || headRef)) {
|
||||||
|
throw new Error("Use either --selection-mode or --base-ref/--head-ref, not both.");
|
||||||
|
}
|
||||||
|
if ((baseRef && !headRef) || (!baseRef && headRef)) {
|
||||||
|
throw new Error("Both --base-ref and --head-ref are required together.");
|
||||||
|
}
|
||||||
|
|
||||||
|
return { selection, selectionMode, pluginsFlagProvided, baseRef, headRef };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function collectPublishablePluginPackageErrors(
|
||||||
|
candidate: PublishablePluginPackageCandidate,
|
||||||
|
): string[] {
|
||||||
|
const { packageJson } = candidate;
|
||||||
|
const errors: string[] = [];
|
||||||
|
const packageName = packageJson.name?.trim() ?? "";
|
||||||
|
const packageVersion = packageJson.version?.trim() ?? "";
|
||||||
|
const extensions = packageJson.openclaw?.extensions ?? [];
|
||||||
|
|
||||||
|
if (!packageName.startsWith("@openclaw/")) {
|
||||||
|
errors.push(
|
||||||
|
`package name must start with "@openclaw/"; found "${packageName || "<missing>"}".`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (packageJson.private === true) {
|
||||||
|
errors.push("package.json private must not be true.");
|
||||||
|
}
|
||||||
|
if (!packageVersion) {
|
||||||
|
errors.push("package.json version must be non-empty.");
|
||||||
|
} else if (parseReleaseVersion(packageVersion) === null) {
|
||||||
|
errors.push(
|
||||||
|
`package.json version must match YYYY.M.D or YYYY.M.D-beta.N; found "${packageVersion}".`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (!Array.isArray(extensions) || extensions.length === 0) {
|
||||||
|
errors.push("openclaw.extensions must contain at least one entry.");
|
||||||
|
}
|
||||||
|
if (extensions.some((entry) => typeof entry !== "string" || !entry.trim())) {
|
||||||
|
errors.push("openclaw.extensions must contain only non-empty strings.");
|
||||||
|
}
|
||||||
|
|
||||||
|
return errors;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function collectPublishablePluginPackages(
|
||||||
|
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?.publishToNpm !== true) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const candidate = {
|
||||||
|
extensionId: dir.name,
|
||||||
|
packageDir,
|
||||||
|
packageJson,
|
||||||
|
} satisfies PublishablePluginPackageCandidate;
|
||||||
|
const errors = collectPublishablePluginPackageErrors(candidate);
|
||||||
|
if (errors.length > 0) {
|
||||||
|
validationErrors.push(...errors.map((error) => `${dir.name}: ${error}`));
|
||||||
|
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 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",
|
||||||
|
installNpmSpec: packageJson.openclaw?.install?.npmSpec?.trim() || undefined,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (validationErrors.length > 0) {
|
||||||
|
throw new Error(
|
||||||
|
`Publishable plugin metadata validation failed:\n${validationErrors.map((error) => `- ${error}`).join("\n")}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return publishable.toSorted((left, right) => left.packageName.localeCompare(right.packageName));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveSelectedPublishablePluginPackages(params: {
|
||||||
|
plugins: PublishablePluginPackage[];
|
||||||
|
selection: string[];
|
||||||
|
}): PublishablePluginPackage[] {
|
||||||
|
if (params.selection.length === 0) {
|
||||||
|
return params.plugins;
|
||||||
|
}
|
||||||
|
|
||||||
|
const byName = new Map(params.plugins.map((plugin) => [plugin.packageName, plugin]));
|
||||||
|
const selected: PublishablePluginPackage[] = [];
|
||||||
|
const missing: string[] = [];
|
||||||
|
|
||||||
|
for (const packageName of params.selection) {
|
||||||
|
const plugin = byName.get(packageName);
|
||||||
|
if (!plugin) {
|
||||||
|
missing.push(packageName);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
selected.push(plugin);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (missing.length > 0) {
|
||||||
|
throw new Error(`Unknown or non-publishable plugin package selection: ${missing.join(", ")}.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return selected;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function collectChangedExtensionIdsFromPaths(paths: readonly string[]): string[] {
|
||||||
|
const extensionIds = new Set<string>();
|
||||||
|
|
||||||
|
for (const path of paths) {
|
||||||
|
const normalized = path.trim().replaceAll("\\", "/");
|
||||||
|
const match = /^extensions\/([^/]+)\//.exec(normalized);
|
||||||
|
if (match?.[1]) {
|
||||||
|
extensionIds.add(match[1]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return [...extensionIds].toSorted();
|
||||||
|
}
|
||||||
|
|
||||||
|
function isNullGitRef(ref: string | undefined): boolean {
|
||||||
|
return !ref || /^0+$/.test(ref);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function collectChangedExtensionIdsFromGitRange(params: {
|
||||||
|
rootDir?: string;
|
||||||
|
gitRange: GitRangeSelection;
|
||||||
|
}): string[] {
|
||||||
|
const rootDir = params.rootDir ?? resolve(".");
|
||||||
|
const { baseRef, headRef } = params.gitRange;
|
||||||
|
|
||||||
|
if (isNullGitRef(baseRef) || isNullGitRef(headRef)) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const changedPaths = execFileSync(
|
||||||
|
"git",
|
||||||
|
["diff", "--name-only", "--diff-filter=ACMR", baseRef, headRef, "--", "extensions"],
|
||||||
|
{
|
||||||
|
cwd: rootDir,
|
||||||
|
encoding: "utf8",
|
||||||
|
stdio: ["ignore", "pipe", "pipe"],
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.split("\n")
|
||||||
|
.map((line) => line.trim())
|
||||||
|
.filter(Boolean);
|
||||||
|
|
||||||
|
return collectChangedExtensionIdsFromPaths(changedPaths);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveChangedPublishablePluginPackages(params: {
|
||||||
|
plugins: PublishablePluginPackage[];
|
||||||
|
changedExtensionIds: readonly string[];
|
||||||
|
}): PublishablePluginPackage[] {
|
||||||
|
if (params.changedExtensionIds.length === 0) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const changed = new Set(params.changedExtensionIds);
|
||||||
|
return params.plugins.filter((plugin) => changed.has(plugin.extensionId));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isPluginVersionPublished(packageName: string, version: string): boolean {
|
||||||
|
const tempDir = mkdtempSync(join(tmpdir(), "openclaw-plugin-npm-view-"));
|
||||||
|
const userconfigPath = join(tempDir, "npmrc");
|
||||||
|
writeFileSync(userconfigPath, "");
|
||||||
|
|
||||||
|
try {
|
||||||
|
execFileSync(
|
||||||
|
"npm",
|
||||||
|
["view", `${packageName}@${version}`, "version", "--userconfig", userconfigPath],
|
||||||
|
{
|
||||||
|
encoding: "utf8",
|
||||||
|
stdio: ["ignore", "pipe", "pipe"],
|
||||||
|
},
|
||||||
|
);
|
||||||
|
return true;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
} finally {
|
||||||
|
rmSync(tempDir, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function collectPluginReleasePlan(params?: {
|
||||||
|
rootDir?: string;
|
||||||
|
selection?: string[];
|
||||||
|
selectionMode?: PluginReleaseSelectionMode;
|
||||||
|
gitRange?: GitRangeSelection;
|
||||||
|
}): PluginReleasePlan {
|
||||||
|
const allPublishable = collectPublishablePluginPackages(params?.rootDir);
|
||||||
|
const selectedPublishable =
|
||||||
|
params?.selectionMode === "all-publishable"
|
||||||
|
? allPublishable
|
||||||
|
: params?.selection && params.selection.length > 0
|
||||||
|
? resolveSelectedPublishablePluginPackages({
|
||||||
|
plugins: allPublishable,
|
||||||
|
selection: params.selection,
|
||||||
|
})
|
||||||
|
: params?.gitRange
|
||||||
|
? resolveChangedPublishablePluginPackages({
|
||||||
|
plugins: allPublishable,
|
||||||
|
changedExtensionIds: collectChangedExtensionIdsFromGitRange({
|
||||||
|
rootDir: params.rootDir,
|
||||||
|
gitRange: params.gitRange,
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
: allPublishable;
|
||||||
|
|
||||||
|
const all = selectedPublishable.map((plugin) => ({
|
||||||
|
...plugin,
|
||||||
|
alreadyPublished: isPluginVersionPublished(plugin.packageName, plugin.version),
|
||||||
|
}));
|
||||||
|
|
||||||
|
return {
|
||||||
|
all,
|
||||||
|
candidates: all.filter((plugin) => !plugin.alreadyPublished),
|
||||||
|
skippedPublished: all.filter((plugin) => plugin.alreadyPublished),
|
||||||
|
};
|
||||||
|
}
|
||||||
45
scripts/plugin-npm-publish.sh
Normal file
45
scripts/plugin-npm-publish.sh
Normal file
@ -0,0 +1,45 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
mode="${1:-}"
|
||||||
|
package_dir="${2:-}"
|
||||||
|
|
||||||
|
if [[ "${mode}" != "--dry-run" && "${mode}" != "--publish" ]]; then
|
||||||
|
echo "usage: bash scripts/plugin-npm-publish.sh [--dry-run|--publish] <package-dir>" >&2
|
||||||
|
exit 2
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ -z "${package_dir}" ]]; then
|
||||||
|
echo "missing package dir" >&2
|
||||||
|
exit 2
|
||||||
|
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_cmd=(npm publish --access public --provenance)
|
||||||
|
release_channel="stable"
|
||||||
|
|
||||||
|
if [[ "${package_version}" == *-beta.* ]]; then
|
||||||
|
publish_cmd=(npm publish --access public --tag beta --provenance)
|
||||||
|
release_channel="beta"
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "Resolved package dir: ${package_dir}"
|
||||||
|
echo "Resolved package name: ${package_name}"
|
||||||
|
echo "Resolved package version: ${package_version}"
|
||||||
|
echo "Resolved release channel: ${release_channel}"
|
||||||
|
echo "Publish auth: GitHub OIDC trusted publishing"
|
||||||
|
|
||||||
|
printf 'Publish command:'
|
||||||
|
printf ' %q' "${publish_cmd[@]}"
|
||||||
|
printf '\n'
|
||||||
|
|
||||||
|
if [[ "${mode}" == "--dry-run" ]]; then
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
(
|
||||||
|
cd "${package_dir}"
|
||||||
|
"${publish_cmd[@]}"
|
||||||
|
)
|
||||||
47
scripts/plugin-npm-release-check.ts
Normal file
47
scripts/plugin-npm-release-check.ts
Normal file
@ -0,0 +1,47 @@
|
|||||||
|
#!/usr/bin/env -S node --import tsx
|
||||||
|
|
||||||
|
import { pathToFileURL } from "node:url";
|
||||||
|
import {
|
||||||
|
collectChangedExtensionIdsFromGitRange,
|
||||||
|
collectPublishablePluginPackages,
|
||||||
|
parsePluginReleaseArgs,
|
||||||
|
resolveChangedPublishablePluginPackages,
|
||||||
|
resolveSelectedPublishablePluginPackages,
|
||||||
|
} from "./lib/plugin-npm-release.ts";
|
||||||
|
|
||||||
|
export function runPluginNpmReleaseCheck(argv: string[]) {
|
||||||
|
const { selection, selectionMode, baseRef, headRef } = parsePluginReleaseArgs(argv);
|
||||||
|
const publishable = collectPublishablePluginPackages();
|
||||||
|
const selected =
|
||||||
|
selectionMode === "all-publishable"
|
||||||
|
? publishable
|
||||||
|
: selection.length > 0
|
||||||
|
? resolveSelectedPublishablePluginPackages({
|
||||||
|
plugins: publishable,
|
||||||
|
selection,
|
||||||
|
})
|
||||||
|
: baseRef && headRef
|
||||||
|
? resolveChangedPublishablePluginPackages({
|
||||||
|
plugins: publishable,
|
||||||
|
changedExtensionIds: collectChangedExtensionIdsFromGitRange({
|
||||||
|
gitRange: { baseRef, headRef },
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
: publishable;
|
||||||
|
|
||||||
|
console.log("plugin-npm-release-check: publishable plugin metadata looks OK.");
|
||||||
|
if (baseRef && headRef && selected.length === 0) {
|
||||||
|
console.log(
|
||||||
|
` - no publishable plugin package changes detected between ${baseRef} and ${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) {
|
||||||
|
runPluginNpmReleaseCheck(process.argv.slice(2));
|
||||||
|
}
|
||||||
18
scripts/plugin-npm-release-plan.ts
Normal file
18
scripts/plugin-npm-release-plan.ts
Normal file
@ -0,0 +1,18 @@
|
|||||||
|
#!/usr/bin/env -S node --import tsx
|
||||||
|
|
||||||
|
import { pathToFileURL } from "node:url";
|
||||||
|
import { collectPluginReleasePlan, parsePluginReleaseArgs } from "./lib/plugin-npm-release.ts";
|
||||||
|
|
||||||
|
export function collectPluginNpmReleasePlan(argv: string[]) {
|
||||||
|
const { selection, selectionMode, baseRef, headRef } = parsePluginReleaseArgs(argv);
|
||||||
|
return collectPluginReleasePlan({
|
||||||
|
selection,
|
||||||
|
selectionMode,
|
||||||
|
gitRange: baseRef && headRef ? { baseRef, headRef } : undefined,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (import.meta.url === pathToFileURL(process.argv[1] ?? "").href) {
|
||||||
|
const plan = collectPluginNpmReleasePlan(process.argv.slice(2));
|
||||||
|
console.log(JSON.stringify(plan, null, 2));
|
||||||
|
}
|
||||||
@ -34,15 +34,6 @@ const appcastPath = resolve("appcast.xml");
|
|||||||
const laneBuildMin = 1_000_000_000;
|
const laneBuildMin = 1_000_000_000;
|
||||||
const laneFloorAdoptionDateKey = 20260227;
|
const laneFloorAdoptionDateKey = 20260227;
|
||||||
|
|
||||||
function normalizePluginSyncVersion(version: string): string {
|
|
||||||
const normalized = version.trim().replace(/^v/, "");
|
|
||||||
const base = /^([0-9]+\.[0-9]+\.[0-9]+)/.exec(normalized)?.[1];
|
|
||||||
if (base) {
|
|
||||||
return base;
|
|
||||||
}
|
|
||||||
return normalized.replace(/[-+].*$/, "");
|
|
||||||
}
|
|
||||||
|
|
||||||
export function collectBundledExtensionRootDependencyGapErrors(params: {
|
export function collectBundledExtensionRootDependencyGapErrors(params: {
|
||||||
rootPackage: PackageJson;
|
rootPackage: PackageJson;
|
||||||
extensions: BundledExtension[];
|
extensions: BundledExtension[];
|
||||||
@ -190,54 +181,6 @@ export function collectPackUnpackedSizeErrors(results: Iterable<PackResult>): st
|
|||||||
return errors;
|
return errors;
|
||||||
}
|
}
|
||||||
|
|
||||||
function checkPluginVersions() {
|
|
||||||
const rootPackagePath = resolve("package.json");
|
|
||||||
const rootPackage = JSON.parse(readFileSync(rootPackagePath, "utf8")) as PackageJson;
|
|
||||||
const targetVersion = rootPackage.version;
|
|
||||||
const targetBaseVersion = targetVersion ? normalizePluginSyncVersion(targetVersion) : null;
|
|
||||||
|
|
||||||
if (!targetVersion || !targetBaseVersion) {
|
|
||||||
console.error("release-check: root package.json missing version.");
|
|
||||||
process.exit(1);
|
|
||||||
}
|
|
||||||
|
|
||||||
const extensionsDir = resolve("extensions");
|
|
||||||
const entries = readdirSync(extensionsDir, { withFileTypes: true }).filter((entry) =>
|
|
||||||
entry.isDirectory(),
|
|
||||||
);
|
|
||||||
|
|
||||||
const mismatches: string[] = [];
|
|
||||||
|
|
||||||
for (const entry of entries) {
|
|
||||||
const packagePath = join(extensionsDir, entry.name, "package.json");
|
|
||||||
let pkg: PackageJson;
|
|
||||||
try {
|
|
||||||
pkg = JSON.parse(readFileSync(packagePath, "utf8")) as PackageJson;
|
|
||||||
} catch {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!pkg.name || !pkg.version) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (normalizePluginSyncVersion(pkg.version) !== targetBaseVersion) {
|
|
||||||
mismatches.push(`${pkg.name} (${pkg.version})`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (mismatches.length > 0) {
|
|
||||||
console.error(
|
|
||||||
`release-check: plugin versions must match release base ${targetBaseVersion} (root ${targetVersion}):`,
|
|
||||||
);
|
|
||||||
for (const item of mismatches) {
|
|
||||||
console.error(` - ${item}`);
|
|
||||||
}
|
|
||||||
console.error("release-check: run `pnpm plugins:sync` to align plugin versions.");
|
|
||||||
process.exit(1);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function extractTag(item: string, tag: string): string | null {
|
function extractTag(item: string, tag: string): string | null {
|
||||||
const escapedTag = tag.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
const escapedTag = tag.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||||
const regex = new RegExp(`<${escapedTag}>([^<]+)</${escapedTag}>`);
|
const regex = new RegExp(`<${escapedTag}>([^<]+)</${escapedTag}>`);
|
||||||
@ -393,7 +336,6 @@ async function checkPluginSdkExports() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function main() {
|
async function main() {
|
||||||
checkPluginVersions();
|
|
||||||
checkAppcastSparkleVersions();
|
checkAppcastSparkleVersions();
|
||||||
await checkPluginSdkExports();
|
await checkPluginSdkExports();
|
||||||
checkBundledExtensionRootDependencyMirrors();
|
checkBundledExtensionRootDependencyMirrors();
|
||||||
|
|||||||
217
test/plugin-npm-release.test.ts
Normal file
217
test/plugin-npm-release.test.ts
Normal file
@ -0,0 +1,217 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import {
|
||||||
|
collectChangedExtensionIdsFromPaths,
|
||||||
|
collectPublishablePluginPackageErrors,
|
||||||
|
parsePluginReleaseArgs,
|
||||||
|
parsePluginReleaseSelection,
|
||||||
|
parsePluginReleaseSelectionMode,
|
||||||
|
resolveChangedPublishablePluginPackages,
|
||||||
|
resolveSelectedPublishablePluginPackages,
|
||||||
|
type PublishablePluginPackage,
|
||||||
|
} from "../scripts/lib/plugin-npm-release.ts";
|
||||||
|
|
||||||
|
describe("parsePluginReleaseSelection", () => {
|
||||||
|
it("returns an empty list for blank input", () => {
|
||||||
|
expect(parsePluginReleaseSelection("")).toEqual([]);
|
||||||
|
expect(parsePluginReleaseSelection(" ")).toEqual([]);
|
||||||
|
expect(parsePluginReleaseSelection(undefined)).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("dedupes and sorts comma or whitespace separated package names", () => {
|
||||||
|
expect(
|
||||||
|
parsePluginReleaseSelection(" @openclaw/zalo, @openclaw/feishu @openclaw/zalo "),
|
||||||
|
).toEqual(["@openclaw/feishu", "@openclaw/zalo"]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("parsePluginReleaseSelectionMode", () => {
|
||||||
|
it("accepts the supported explicit selection modes", () => {
|
||||||
|
expect(parsePluginReleaseSelectionMode("selected")).toBe("selected");
|
||||||
|
expect(parsePluginReleaseSelectionMode("all-publishable")).toBe("all-publishable");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects unsupported selection modes", () => {
|
||||||
|
expect(() => parsePluginReleaseSelectionMode("all")).toThrowError(
|
||||||
|
'Unknown selection mode: all. Expected "selected" or "all-publishable".',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("parsePluginReleaseArgs", () => {
|
||||||
|
it("rejects blank explicit plugin selections", () => {
|
||||||
|
expect(() => parsePluginReleaseArgs(["--plugins", " "])).toThrowError(
|
||||||
|
"`--plugins` must include at least one package name.",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("requires plugin names for selected explicit publish mode", () => {
|
||||||
|
expect(() => parsePluginReleaseArgs(["--selection-mode", "selected"])).toThrowError(
|
||||||
|
"`--selection-mode selected` requires `--plugins`.",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects plugin names when all-publishable mode is selected", () => {
|
||||||
|
expect(() =>
|
||||||
|
parsePluginReleaseArgs([
|
||||||
|
"--selection-mode",
|
||||||
|
"all-publishable",
|
||||||
|
"--plugins",
|
||||||
|
"@openclaw/zalo",
|
||||||
|
]),
|
||||||
|
).toThrowError("`--selection-mode all-publishable` must not be combined with `--plugins`.");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("parses explicit all-publishable mode", () => {
|
||||||
|
expect(parsePluginReleaseArgs(["--selection-mode", "all-publishable"])).toMatchObject({
|
||||||
|
selectionMode: "all-publishable",
|
||||||
|
selection: [],
|
||||||
|
pluginsFlagProvided: false,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("collectPublishablePluginPackageErrors", () => {
|
||||||
|
it("accepts a valid publishable plugin package candidate", () => {
|
||||||
|
expect(
|
||||||
|
collectPublishablePluginPackageErrors({
|
||||||
|
extensionId: "zalo",
|
||||||
|
packageDir: "extensions/zalo",
|
||||||
|
packageJson: {
|
||||||
|
name: "@openclaw/zalo",
|
||||||
|
version: "2026.3.15",
|
||||||
|
openclaw: {
|
||||||
|
extensions: ["./index.ts"],
|
||||||
|
release: {
|
||||||
|
publishToNpm: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("flags invalid publishable plugin metadata", () => {
|
||||||
|
expect(
|
||||||
|
collectPublishablePluginPackageErrors({
|
||||||
|
extensionId: "broken",
|
||||||
|
packageDir: "extensions/broken",
|
||||||
|
packageJson: {
|
||||||
|
name: "broken",
|
||||||
|
version: "latest",
|
||||||
|
private: true,
|
||||||
|
openclaw: {
|
||||||
|
extensions: [""],
|
||||||
|
release: {
|
||||||
|
publishToNpm: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
).toEqual([
|
||||||
|
'package name must start with "@openclaw/"; found "broken".',
|
||||||
|
"package.json private must not be true.",
|
||||||
|
'package.json version must match YYYY.M.D or YYYY.M.D-beta.N; found "latest".',
|
||||||
|
"openclaw.extensions must contain only non-empty strings.",
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("resolveSelectedPublishablePluginPackages", () => {
|
||||||
|
const publishablePlugins: PublishablePluginPackage[] = [
|
||||||
|
{
|
||||||
|
extensionId: "feishu",
|
||||||
|
packageDir: "extensions/feishu",
|
||||||
|
packageName: "@openclaw/feishu",
|
||||||
|
version: "2026.3.15",
|
||||||
|
channel: "stable",
|
||||||
|
publishTag: "latest",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
extensionId: "zalo",
|
||||||
|
packageDir: "extensions/zalo",
|
||||||
|
packageName: "@openclaw/zalo",
|
||||||
|
version: "2026.3.15-beta.1",
|
||||||
|
channel: "beta",
|
||||||
|
publishTag: "beta",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
it("returns all publishable plugins when no selection is provided", () => {
|
||||||
|
expect(
|
||||||
|
resolveSelectedPublishablePluginPackages({
|
||||||
|
plugins: publishablePlugins,
|
||||||
|
selection: [],
|
||||||
|
}),
|
||||||
|
).toEqual(publishablePlugins);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("filters by selected publishable package names", () => {
|
||||||
|
expect(
|
||||||
|
resolveSelectedPublishablePluginPackages({
|
||||||
|
plugins: publishablePlugins,
|
||||||
|
selection: ["@openclaw/zalo"],
|
||||||
|
}),
|
||||||
|
).toEqual([publishablePlugins[1]]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("throws when the selection contains an unknown package name", () => {
|
||||||
|
expect(() =>
|
||||||
|
resolveSelectedPublishablePluginPackages({
|
||||||
|
plugins: publishablePlugins,
|
||||||
|
selection: ["@openclaw/missing"],
|
||||||
|
}),
|
||||||
|
).toThrowError("Unknown or non-publishable plugin package selection: @openclaw/missing.");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("collectChangedExtensionIdsFromPaths", () => {
|
||||||
|
it("extracts unique extension ids from changed extension paths", () => {
|
||||||
|
expect(
|
||||||
|
collectChangedExtensionIdsFromPaths([
|
||||||
|
"extensions/zalo/index.ts",
|
||||||
|
"extensions/zalo/package.json",
|
||||||
|
"extensions/feishu/src/client.ts",
|
||||||
|
"docs/reference/RELEASING.md",
|
||||||
|
]),
|
||||||
|
).toEqual(["feishu", "zalo"]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("resolveChangedPublishablePluginPackages", () => {
|
||||||
|
const publishablePlugins: PublishablePluginPackage[] = [
|
||||||
|
{
|
||||||
|
extensionId: "feishu",
|
||||||
|
packageDir: "extensions/feishu",
|
||||||
|
packageName: "@openclaw/feishu",
|
||||||
|
version: "2026.3.15",
|
||||||
|
channel: "stable",
|
||||||
|
publishTag: "latest",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
extensionId: "zalo",
|
||||||
|
packageDir: "extensions/zalo",
|
||||||
|
packageName: "@openclaw/zalo",
|
||||||
|
version: "2026.3.15-beta.1",
|
||||||
|
channel: "beta",
|
||||||
|
publishTag: "beta",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
it("returns only changed publishable plugins", () => {
|
||||||
|
expect(
|
||||||
|
resolveChangedPublishablePluginPackages({
|
||||||
|
plugins: publishablePlugins,
|
||||||
|
changedExtensionIds: ["zalo"],
|
||||||
|
}),
|
||||||
|
).toEqual([publishablePlugins[1]]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns an empty list when no publishable plugins changed", () => {
|
||||||
|
expect(
|
||||||
|
resolveChangedPublishablePluginPackages({
|
||||||
|
plugins: publishablePlugins,
|
||||||
|
changedExtensionIds: [],
|
||||||
|
}),
|
||||||
|
).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
Loading…
x
Reference in New Issue
Block a user