Files
openclaw/.github/workflows/plugin-prerelease.yml
2026-08-20 23:41:42 -07:00

926 lines
39 KiB
YAML

name: Plugin Prerelease
run-name: ${{ inputs.dispatch_id != '' && format('Plugin Prerelease {0}', inputs.dispatch_id) || 'Plugin Prerelease' }}
on:
workflow_dispatch:
inputs:
target_ref:
description: Branch, tag, or full commit SHA to validate
required: false
default: main
type: string
expected_sha:
description: Optional full commit SHA that target_ref must resolve to
required: false
default: ""
type: string
full_release_validation:
description: Enable release-only Docker prerelease lanes from Full Release Validation
required: false
default: false
type: boolean
dispatch_id:
description: Optional parent workflow dispatch identifier
required: false
default: ""
type: string
candidate_artifact_json:
description: Immutable package and Docker image artifact tuple from Full Release Validation
required: false
default: ""
type: string
permissions:
contents: read
concurrency:
group: plugin-prerelease-${{ inputs.target_ref }}-${{ github.sha }}
cancel-in-progress: ${{ inputs.target_ref == 'main' }}
env:
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true"
jobs:
resolve-candidate:
name: Resolve plugin prerelease candidate
runs-on: ubuntu-24.04
timeout-minutes: 5
outputs:
checkout_revision: ${{ steps.resolve.outputs.checkout_revision }}
steps:
- name: Checkout trusted workflow helper
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
with:
ref: ${{ github.sha }}
fetch-depth: 1
fetch-tags: false
persist-credentials: false
submodules: false
- name: Resolve and validate candidate
id: resolve
env:
EXPECTED_SHA: ${{ inputs.expected_sha }}
TARGET_REF: ${{ inputs.target_ref }}
shell: bash
run: |
set -euo pipefail
resolver_output="$RUNNER_TEMP/plugin-prerelease-resolved-ref"
bash scripts/github/resolve-openclaw-ref.sh \
--ref "$TARGET_REF" \
--expected-sha "$EXPECTED_SHA" \
--github-output "$resolver_output"
checkout_revision="$(sed -n 's/^sha=//p' "$resolver_output")"
[[ "$checkout_revision" =~ ^[0-9a-f]{40}$ ]] || {
echo "Resolved plugin prerelease candidate is not a full lowercase commit SHA." >&2
exit 1
}
timeout --signal=TERM --kill-after=10s 120s \
git fetch --no-tags --no-recurse-submodules --depth=1 origin "$checkout_revision"
fetched_revision="$(git rev-parse FETCH_HEAD)"
[[ "$fetched_revision" == "$checkout_revision" ]] || {
echo "Fetched plugin prerelease candidate differs from the resolved commit." >&2
exit 1
}
echo "checkout_revision=$checkout_revision" >> "$GITHUB_OUTPUT"
preflight:
name: Build plugin prerelease plan
needs: [resolve-candidate]
runs-on: ubuntu-24.04
timeout-minutes: 15
outputs:
run_plugin_prerelease_suite: ${{ steps.manifest.outputs.run_plugin_prerelease_suite }}
run_plugin_prerelease_static: ${{ steps.manifest.outputs.run_plugin_prerelease_static }}
plugin_prerelease_static_matrix: ${{ steps.manifest.outputs.plugin_prerelease_static_matrix }}
run_plugin_prerelease_node: ${{ steps.manifest.outputs.run_plugin_prerelease_node }}
plugin_prerelease_node_matrix: ${{ steps.manifest.outputs.plugin_prerelease_node_matrix }}
run_plugin_prerelease_extensions: ${{ steps.manifest.outputs.run_plugin_prerelease_extensions }}
plugin_prerelease_extension_matrix: ${{ steps.manifest.outputs.plugin_prerelease_extension_matrix }}
run_plugin_prerelease_docker: ${{ steps.manifest.outputs.run_plugin_prerelease_docker }}
plugin_prerelease_docker_lanes: ${{ steps.manifest.outputs.plugin_prerelease_docker_lanes }}
steps:
- name: Checkout target
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
with:
ref: ${{ needs.resolve-candidate.outputs.checkout_revision }}
fetch-depth: 1
fetch-tags: false
persist-credentials: false
submodules: false
- name: Setup manifest TypeScript runtime
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
node-version: "24.x"
- name: Setup manifest pnpm
uses: ./.github/actions/setup-pnpm-store-cache
with:
node-version: "24.x"
- name: Install manifest dependencies
run: pnpm install --frozen-lockfile --prefer-offline --ignore-scripts
- name: Build plugin prerelease manifest
id: manifest
env:
FULL_RELEASE_VALIDATION: ${{ inputs.full_release_validation && 'true' || 'false' }}
run: |
node --import tsx --input-type=module <<'EOF'
import { appendFileSync, existsSync } from "node:fs";
const createMatrix = (include) => ({ include });
const outputPath = process.env.GITHUB_OUTPUT;
const fullReleaseValidation = process.env.FULL_RELEASE_VALIDATION === "true";
let pluginPrereleasePlan = { staticChecks: [], dockerLanes: [] };
let extensionShards = [];
let nodeShards = [];
const targetPlanPaths = {
"plugin-prerelease-test-plan": "./scripts/lib/plugin-prerelease-test-plan.mts",
"extension-test-plan": "./scripts/lib/extension-test-plan.mts",
"ci-node-test-plan": "./scripts/lib/ci-node-test-plan.mts",
};
const targetPlanPath = (name) => {
const mtsPath = targetPlanPaths[name];
return existsSync(mtsPath) ? mtsPath : mtsPath.replace(/\.mts$/u, ".mjs");
};
try {
const { assertPluginPrereleaseTestPlanComplete } = await import(
targetPlanPath("plugin-prerelease-test-plan")
);
pluginPrereleasePlan = assertPluginPrereleaseTestPlanComplete();
} catch (error) {
const errorCode =
error && typeof error === "object" && "code" in error ? error.code : "";
const moduleUrl =
error && typeof error === "object" && "url" in error ? String(error.url) : "";
if (
errorCode === "ERR_MODULE_NOT_FOUND" &&
(moduleUrl.endsWith("/scripts/lib/plugin-prerelease-test-plan.mjs") ||
moduleUrl.endsWith("/scripts/lib/plugin-prerelease-test-plan.mts"))
) {
console.warn(
"Plugin prerelease plan unavailable in target ref; skipping static and Docker plugin prerelease lanes.",
);
} else {
throw error;
}
}
try {
const { createExtensionTestShards, DEFAULT_EXTENSION_TEST_SHARD_COUNT } = await import(
targetPlanPath("extension-test-plan")
);
extensionShards = createExtensionTestShards({
shardCount: DEFAULT_EXTENSION_TEST_SHARD_COUNT,
}).map((shard) => ({
check_name: shard.checkName,
extensions_csv: shard.extensionIds.join(","),
vitest_max_workers: shard.extensionIds.some((extensionId) =>
extensionId.startsWith("memory-"),
)
? 4
: 1,
runner: shard.extensionIds.some((extensionId) => extensionId.startsWith("memory-"))
? "blacksmith-16vcpu-ubuntu-2404"
: [0, 1, 2, 3].includes(shard.index)
? "blacksmith-8vcpu-ubuntu-2404"
: "blacksmith-4vcpu-ubuntu-2404",
shard_index: shard.index + 1,
task: "extensions-batch",
}));
} catch (error) {
const errorCode =
error && typeof error === "object" && "code" in error ? error.code : "";
const moduleUrl =
error && typeof error === "object" && "url" in error ? String(error.url) : "";
if (
errorCode === "ERR_MODULE_NOT_FOUND" &&
(moduleUrl.endsWith("/scripts/lib/extension-test-plan.mjs") ||
moduleUrl.endsWith("/scripts/lib/extension-test-plan.mts"))
) {
console.warn(
"Extension test plan unavailable in target ref; skipping extension prerelease shards.",
);
} else {
throw error;
}
}
try {
const { createNodeTestShards } = await import(targetPlanPath("ci-node-test-plan"));
nodeShards = createNodeTestShards({
includeReleaseOnlyPluginShards: true,
})
.filter((shard) => shard.shardName === "agentic-plugins")
.map((shard) => ({
check_name: shard.checkName,
runtime: "node",
task: "test-shard",
shard_name: shard.shardName,
configs: shard.configs,
includePatterns: shard.includePatterns,
runner: shard.runner,
}));
} catch (error) {
const errorCode =
error && typeof error === "object" && "code" in error ? error.code : "";
const moduleUrl =
error && typeof error === "object" && "url" in error ? String(error.url) : "";
if (
errorCode === "ERR_MODULE_NOT_FOUND" &&
(moduleUrl.endsWith("/scripts/lib/ci-node-test-plan.mjs") ||
moduleUrl.endsWith("/scripts/lib/ci-node-test-plan.mts"))
) {
console.warn(
"Node test plan unavailable in target ref; skipping release-only plugin Node shard.",
);
} else {
throw error;
}
}
const staticChecks = pluginPrereleasePlan.staticChecks.map((check) => ({
check_name: check.checkName,
command: check.command,
task: check.check,
}));
const dockerLanes = pluginPrereleasePlan.dockerLanes;
const runStatic = staticChecks.length > 0;
const runNode = nodeShards.length > 0;
const runExtensions = extensionShards.length > 0;
const runDocker = fullReleaseValidation && dockerLanes.length > 0;
const runSuite = runStatic || runNode || runExtensions || runDocker;
const manifest = {
run_plugin_prerelease_suite: runSuite,
run_plugin_prerelease_static: runStatic,
plugin_prerelease_static_matrix: createMatrix(staticChecks),
run_plugin_prerelease_node: runNode,
plugin_prerelease_node_matrix: createMatrix(nodeShards),
run_plugin_prerelease_extensions: runExtensions,
plugin_prerelease_extension_matrix: createMatrix(extensionShards),
run_plugin_prerelease_docker: runDocker,
plugin_prerelease_docker_lanes: dockerLanes.join(" "),
};
for (const [key, value] of Object.entries(manifest)) {
appendFileSync(
outputPath,
`${key}=${typeof value === "string" ? value : JSON.stringify(value)}\n`,
"utf8",
);
}
EOF
plugin-npm-security-plan:
permissions:
contents: read
name: Plan plugin npm security artifacts
needs: [resolve-candidate]
runs-on: ubuntu-24.04
timeout-minutes: 10
outputs:
matrix: ${{ steps.plan.outputs.matrix }}
packages_json: ${{ steps.plan.outputs.packages_json }}
steps:
- name: Checkout trusted scanner tooling
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
with:
ref: ${{ github.sha }}
fetch-depth: 1
fetch-tags: false
persist-credentials: false
submodules: false
- name: Checkout candidate as inert data
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
with:
ref: ${{ needs.resolve-candidate.outputs.checkout_revision }}
path: .release-candidate
fetch-depth: 1
fetch-tags: false
persist-credentials: false
submodules: false
- name: Setup trusted scanner TypeScript runtime
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
node-version: "24.x"
- name: Setup trusted scanner pnpm
uses: ./.github/actions/setup-pnpm-store-cache
with:
node-version: "24.x"
- name: Install trusted scanner dependencies
run: pnpm install --frozen-lockfile --prefer-offline --ignore-scripts
- name: Build trusted plugin package plan
id: plan
run: |
node --import tsx scripts/plugin-npm-security-prepare.mts plan \
--candidate-root .release-candidate \
--github-output "$GITHUB_OUTPUT"
plugin-npm-security-package:
permissions:
contents: read
name: Package ${{ matrix.package_name }}
needs: [resolve-candidate, plugin-npm-security-plan]
runs-on: blacksmith-8vcpu-ubuntu-2404
timeout-minutes: 30
strategy:
fail-fast: false
max-parallel: 24
matrix: ${{ fromJson(needs.plugin-npm-security-plan.outputs.matrix) }}
steps:
- name: Checkout trusted packaging tooling
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
with:
ref: ${{ github.sha }}
fetch-depth: 1
fetch-tags: false
persist-credentials: false
submodules: false
- name: Checkout candidate package source
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
with:
ref: ${{ needs.resolve-candidate.outputs.checkout_revision }}
path: .release-candidate
fetch-depth: 1
fetch-tags: false
persist-credentials: false
submodules: false
- name: Setup trusted inert pack environment
uses: ./.github/actions/setup-node-env
with:
node-version: "24.x"
install-bun: "false"
- name: Pack supplemental inert plugin input
env:
CANDIDATE_SHA: ${{ needs.resolve-candidate.outputs.checkout_revision }}
EXTENSION_ID: ${{ matrix.extension_id }}
PACKAGE_DIR: ${{ matrix.package_dir }}
PACKAGE_NAME: ${{ matrix.package_name }}
TOOLING_SHA: ${{ github.sha }}
shell: bash
run: |
set -euo pipefail
output_dir="$RUNNER_TEMP/plugin-npm-security-package"
node --import tsx scripts/plugin-npm-security-prepare.mts prepare \
--candidate-root .release-candidate \
--candidate-sha "$CANDIDATE_SHA" \
--extension-id "$EXTENSION_ID" \
--output-dir "$output_dir" \
--package-dir "$PACKAGE_DIR" \
--package-name "$PACKAGE_NAME" \
--tooling-sha "$TOOLING_SHA"
- name: Upload supplemental inert plugin input
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
with:
name: plugin-npm-security-package-${{ needs.resolve-candidate.outputs.checkout_revision }}-${{ matrix.extension_id }}
path: ${{ runner.temp }}/plugin-npm-security-package/
compression-level: 0
if-no-files-found: error
retention-days: 14
plugin-npm-security-scan:
permissions:
actions: read
contents: read
name: plugin-npm-security-scan
needs: [resolve-candidate, plugin-npm-security-plan, plugin-npm-security-package]
if: ${{ !cancelled() && always() && needs.resolve-candidate.result == 'success' && needs.plugin-npm-security-plan.result == 'success' }}
runs-on: ubuntu-24.04
timeout-minutes: 20
steps:
- name: Checkout trusted scanner tooling
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
with:
ref: ${{ github.sha }}
fetch-depth: 1
fetch-tags: false
persist-credentials: false
submodules: false
- name: Setup trusted scanner TypeScript runtime
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
node-version: "24.x"
- name: Setup trusted scanner pnpm
uses: ./.github/actions/setup-pnpm-store-cache
with:
node-version: "24.x"
- name: Install trusted scanner dependencies
run: pnpm install --frozen-lockfile --prefer-offline --ignore-scripts
- name: Bound supplemental inert plugin input downloads
id: artifact-download-plan
continue-on-error: true
env:
CANDIDATE_SHA: ${{ needs.resolve-candidate.outputs.checkout_revision }}
EXPECTED_PACKAGES_JSON: ${{ needs.plugin-npm-security-plan.outputs.packages_json }}
GH_TOKEN: ${{ github.token }}
shell: bash
run: |
set -euo pipefail
metadata="$RUNNER_TEMP/plugin-npm-security-artifact-api.json"
plan="$RUNNER_TEMP/plugin-npm-security-artifact-download-plan.json"
gh api --paginate --slurp \
-H "Accept: application/vnd.github+json" \
-H "X-GitHub-Api-Version: 2026-03-10" \
"repos/${GITHUB_REPOSITORY}/actions/runs/${GITHUB_RUN_ID}/artifacts?per_page=100&direction=asc" \
> "$metadata"
node --import tsx scripts/plugin-npm-security-artifact-plan.mts \
--artifact-metadata-json "$metadata" \
--candidate-sha "$CANDIDATE_SHA" \
--expected-packages-json "$EXPECTED_PACKAGES_JSON" \
--output "$plan"
artifact_ids="$(jq -r '[.artifacts[].id | tostring] | join(",")' "$plan")"
echo "artifact_ids=$artifact_ids" >> "$GITHUB_OUTPUT"
# The pinned action validates server digests and downloads ID-bound artifacts
# in batches of five only after the trusted metadata plan enforces byte bounds.
- name: Download bounded supplemental inert plugin inputs
id: download-bounded-artifacts
if: steps.artifact-download-plan.outcome == 'success' && steps.artifact-download-plan.outputs.artifact_ids != ''
continue-on-error: true
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8
with:
artifact-ids: ${{ steps.artifact-download-plan.outputs.artifact_ids }}
digest-mismatch: error
github-token: ${{ github.token }}
path: ${{ runner.temp }}/plugin-npm-security-packages
repository: ${{ github.repository }}
run-id: ${{ github.run_id }}
- name: Normalize single supplemental inert plugin input
if: steps.download-bounded-artifacts.outcome == 'success'
env:
ARTIFACT_ROOT: ${{ runner.temp }}/plugin-npm-security-packages
PLAN_PATH: ${{ runner.temp }}/plugin-npm-security-artifact-download-plan.json
shell: bash
run: |
set -euo pipefail
[[ "$(jq '.artifacts | length' "$PLAN_PATH")" == "1" ]] || exit 0
artifact_name="$(jq -r '.artifacts[0].name' "$PLAN_PATH")"
staging="$RUNNER_TEMP/plugin-npm-security-single-artifact"
mkdir -p "$staging" "$ARTIFACT_ROOT/$artifact_name"
find "$ARTIFACT_ROOT" -mindepth 1 -maxdepth 1 -type f -exec mv {} "$staging/" \;
find "$staging" -mindepth 1 -maxdepth 1 -type f -exec mv {} "$ARTIFACT_ROOT/$artifact_name/" \;
- name: Scan supplemental inert plugin inputs
env:
CANDIDATE_SHA: ${{ needs.resolve-candidate.outputs.checkout_revision }}
EXPECTED_PACKAGES_JSON: ${{ needs.plugin-npm-security-plan.outputs.packages_json }}
TOOLING_SHA: ${{ github.sha }}
shell: bash
run: |
set -euo pipefail
report="$RUNNER_TEMP/plugin-npm-security-scan.json"
mkdir -p "$RUNNER_TEMP/plugin-npm-security-packages"
node scripts/plugin-npm-security-scan-runner.mjs \
--artifact-plan "$RUNNER_TEMP/plugin-npm-security-artifact-download-plan.json" \
--artifact-root "$RUNNER_TEMP/plugin-npm-security-packages" \
--candidate-sha "$CANDIDATE_SHA" \
--expected-packages-json "$EXPECTED_PACKAGES_JSON" \
--tooling-sha "$TOOLING_SHA" \
--report "$report"
- name: Upload plugin npm security scan report
if: always()
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
with:
name: plugin-npm-security-scan
path: ${{ runner.temp }}/plugin-npm-security-scan.json
if-no-files-found: error
plugin-prerelease-static-shard:
permissions:
contents: read
name: ${{ matrix.check_name }}
needs: [resolve-candidate, preflight]
if: needs.preflight.outputs.run_plugin_prerelease_static == 'true'
runs-on: ${{ github.event_name == 'workflow_dispatch' && 'ubuntu-24.04' || 'blacksmith-8vcpu-ubuntu-2404' }}
timeout-minutes: 45
strategy:
fail-fast: false
matrix: ${{ fromJson(needs.preflight.outputs.plugin_prerelease_static_matrix) }}
steps:
- name: Checkout
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
with:
ref: ${{ needs.resolve-candidate.outputs.checkout_revision }}
fetch-depth: 1
fetch-tags: false
persist-credentials: false
submodules: false
- name: Setup Node environment
uses: ./.github/actions/setup-node-env
with:
install-bun: "false"
- name: Run plugin prerelease static shard
env:
PLUGIN_PRERELEASE_COMMAND: ${{ matrix.command }}
PLUGIN_PRERELEASE_TASK: ${{ matrix.task }}
shell: bash
run: |
set -euo pipefail
echo "Running ${PLUGIN_PRERELEASE_TASK}: ${PLUGIN_PRERELEASE_COMMAND}"
bash -c "$PLUGIN_PRERELEASE_COMMAND"
plugin-prerelease-node-shard:
permissions:
contents: read
name: ${{ matrix.check_name }}
needs: [resolve-candidate, preflight]
if: needs.preflight.outputs.run_plugin_prerelease_node == 'true'
runs-on: ${{ github.event_name == 'workflow_dispatch' && 'ubuntu-24.04' || (matrix.runner || 'ubuntu-24.04') }}
timeout-minutes: 60
strategy:
fail-fast: false
matrix: ${{ fromJson(needs.preflight.outputs.plugin_prerelease_node_matrix) }}
steps:
- name: Checkout
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
with:
ref: ${{ needs.resolve-candidate.outputs.checkout_revision }}
fetch-depth: 1
fetch-tags: false
persist-credentials: false
submodules: false
- name: Setup Node environment
uses: ./.github/actions/setup-node-env
with:
install-bun: "false"
- name: Configure Node test resources
run: echo "OPENCLAW_VITEST_MAX_WORKERS=2" >> "$GITHUB_ENV"
- name: Run release-only plugin Node shard
env:
NODE_OPTIONS: --max-old-space-size=8192
OPENCLAW_NODE_TEST_CONFIGS_JSON: ${{ toJson(matrix.configs) }}
OPENCLAW_NODE_TEST_INCLUDE_PATTERNS_JSON: ${{ toJson(matrix.includePatterns) }}
OPENCLAW_VITEST_SHARD_NAME: ${{ matrix.shard_name }}
OPENCLAW_TEST_PROJECTS_PARALLEL: "2"
shell: bash
run: |
set -euo pipefail
node --input-type=module <<'EOF'
import { spawnSync } from "node:child_process";
import { writeFileSync } from "node:fs";
import { join } from "node:path";
const configs = JSON.parse(process.env.OPENCLAW_NODE_TEST_CONFIGS_JSON ?? "[]");
if (!Array.isArray(configs) || configs.length === 0) {
console.error("Missing node test shard configs");
process.exit(1);
}
const includePatterns = JSON.parse(
process.env.OPENCLAW_NODE_TEST_INCLUDE_PATTERNS_JSON ?? "null",
);
const childEnv = { ...process.env };
if (Array.isArray(includePatterns) && includePatterns.length > 0) {
const includeFile = join(
process.env.RUNNER_TEMP ?? ".",
`node-test-include-${process.env.GITHUB_JOB ?? "local"}-${Date.now()}.json`,
);
writeFileSync(includeFile, JSON.stringify(includePatterns), "utf8");
childEnv.OPENCLAW_VITEST_INCLUDE_FILE = includeFile;
}
const result = spawnSync("pnpm", ["test", "--", ...configs], {
env: childEnv,
stdio: "inherit",
});
process.exit(result.status ?? 1);
EOF
plugin-prerelease-extension-shard:
permissions:
contents: read
name: ${{ matrix.check_name }}
needs: [resolve-candidate, preflight]
if: needs.preflight.outputs.run_plugin_prerelease_extensions == 'true'
runs-on: ${{ github.event_name == 'workflow_dispatch' && 'ubuntu-24.04' || matrix.runner }}
timeout-minutes: 60
strategy:
fail-fast: false
matrix: ${{ fromJson(needs.preflight.outputs.plugin_prerelease_extension_matrix) }}
steps:
- name: Checkout
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
with:
ref: ${{ needs.resolve-candidate.outputs.checkout_revision }}
fetch-depth: 1
fetch-tags: false
persist-credentials: false
submodules: false
- name: Setup Node environment
uses: ./.github/actions/setup-node-env
with:
install-bun: "false"
- name: Run extension shard
env:
NODE_OPTIONS: --max-old-space-size=8192
OPENCLAW_EXTENSION_BATCH_PARALLEL: 2
OPENCLAW_VITEST_MAX_WORKERS: ${{ matrix.vitest_max_workers }}
OPENCLAW_EXTENSION_BATCH: ${{ matrix.extensions_csv }}
run: pnpm test:extensions:batch "$OPENCLAW_EXTENSION_BATCH" -- --retry=1 --exclude extensions/codex/src/app-server/run-attempt.test.ts
plugin-prerelease-inspector:
permissions:
contents: read
name: plugin-prerelease-inspector
needs: [resolve-candidate, preflight]
if: needs.preflight.outputs.run_plugin_prerelease_suite == 'true'
continue-on-error: true
runs-on: ubuntu-24.04
timeout-minutes: 30
steps:
- name: Checkout
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
with:
ref: ${{ needs.resolve-candidate.outputs.checkout_revision }}
fetch-depth: 1
fetch-tags: false
persist-credentials: false
submodules: false
- name: Setup Node environment
uses: ./.github/actions/setup-node-env
with:
install-bun: "false"
- name: Run plugin inspector advisory sweep
env:
OPENCLAW_PLUGIN_INSPECTOR_VERSION: "0.3.10"
OPENCLAW_PLUGIN_INSPECTOR_ROOT: .artifacts/plugin-inspector
shell: bash
run: |
set -euo pipefail
mkdir -p "$OPENCLAW_PLUGIN_INSPECTOR_ROOT"
set +e
node --input-type=module <<'EOF'
import { existsSync } from "node:fs";
import { mkdir, readdir, readFile, writeFile } from "node:fs/promises";
import path from "node:path";
const artifactRoot = process.env.OPENCLAW_PLUGIN_INSPECTOR_ROOT;
if (!artifactRoot) {
throw new Error("OPENCLAW_PLUGIN_INSPECTOR_ROOT is required");
}
const readJson = async (filePath) => JSON.parse(await readFile(filePath, "utf8"));
const inferSeams = (pluginManifest, packageJson) => {
const contracts = Object.keys(pluginManifest?.contracts ?? {});
if (contracts.includes("tools")) {
return ["dynamic-tool"];
}
const openclawPackage = packageJson?.openclaw ?? {};
if (openclawPackage.extensions || openclawPackage.runtimeExtensions) {
return ["plugin-runtime"];
}
return ["plugin-metadata"];
};
const extensionRoot = path.resolve("extensions");
const fixtures = [];
for (const entry of await readdir(extensionRoot, { withFileTypes: true })) {
if (!entry.isDirectory()) {
continue;
}
const relativePath = `extensions/${entry.name}`;
const packagePath = path.join(extensionRoot, entry.name, "package.json");
const manifestPath = path.join(extensionRoot, entry.name, "openclaw.plugin.json");
if (!existsSync(packagePath) || !existsSync(manifestPath)) {
continue;
}
const packageJson = await readJson(packagePath);
const pluginManifest = await readJson(manifestPath);
fixtures.push({
id: entry.name,
name: pluginManifest.name ?? packageJson.name ?? entry.name,
path: relativePath,
priority: "high",
repo: "local",
seams: inferSeams(pluginManifest, packageJson),
why: "bundled OpenClaw plugin prerelease advisory fixture",
});
}
fixtures.sort((left, right) => left.id.localeCompare(right.id));
if (fixtures.length === 0) {
throw new Error("No bundled plugin fixtures found under extensions/");
}
await mkdir(artifactRoot, { recursive: true });
const config = `${JSON.stringify(
{
version: 1,
submoduleRoot: ".",
openclaw: {
defaultCheckoutPath: ".",
},
fixtures,
},
null,
2,
)}\n`;
await writeFile("plugin-inspector.config.json", config, "utf8");
await writeFile(path.join(artifactRoot, "plugin-inspector.config.json"), config, "utf8");
EOF
config_status=$?
set -e
echo "$config_status" > "$OPENCLAW_PLUGIN_INSPECTOR_ROOT/config-exit-code.txt"
if [ "$config_status" -eq 0 ]; then
set +e
npm exec --yes "@openclaw/plugin-inspector@${OPENCLAW_PLUGIN_INSPECTOR_VERSION}" -- ci \
--config plugin-inspector.config.json \
--openclaw "$PWD" \
--out "$OPENCLAW_PLUGIN_INSPECTOR_ROOT/reports" \
--json \
> "$OPENCLAW_PLUGIN_INSPECTOR_ROOT/plugin-inspector-stdout.json" \
2> "$OPENCLAW_PLUGIN_INSPECTOR_ROOT/plugin-inspector-stderr.log"
inspector_status=$?
set -e
else
inspector_status=127
echo "Skipped plugin-inspector because config generation failed." \
> "$OPENCLAW_PLUGIN_INSPECTOR_ROOT/plugin-inspector-stderr.log"
fi
echo "$inspector_status" > "$OPENCLAW_PLUGIN_INSPECTOR_ROOT/exit-code.txt"
node --input-type=module <<'EOF'
import { existsSync } from "node:fs";
import { appendFile, readFile, writeFile } from "node:fs/promises";
import path from "node:path";
const artifactRoot = process.env.OPENCLAW_PLUGIN_INSPECTOR_ROOT;
const summaryPath = path.join(artifactRoot, "reports/plugin-inspector-ci-summary.json");
const markdownPath = path.join(artifactRoot, "reports/plugin-inspector-ci-summary.md");
const configExitCode = (await readFile(path.join(artifactRoot, "config-exit-code.txt"), "utf8")).trim();
const exitCode = (await readFile(path.join(artifactRoot, "exit-code.txt"), "utf8")).trim();
const lines = [
"## Plugin Inspector Advisory",
"",
`Inspector: @openclaw/plugin-inspector@${process.env.OPENCLAW_PLUGIN_INSPECTOR_VERSION}`,
`Config exit code: ${configExitCode}`,
`Exit code: ${exitCode}`,
];
if (existsSync(summaryPath)) {
const summary = JSON.parse(await readFile(summaryPath, "utf8"));
lines.push(
`Status: ${String(summary.status ?? "unknown").toUpperCase()}`,
"",
"| Metric | Count |",
"| --- | ---: |",
`| Hard breakages | ${summary.summary?.breakages ?? 0} |`,
`| Issues | ${summary.summary?.issues ?? 0} |`,
`| P0 issues | ${summary.summary?.p0Issues ?? 0} |`,
`| P1 issues | ${summary.summary?.p1Issues ?? 0} |`,
`| Compat gaps | ${summary.summary?.compatGaps ?? 0} |`,
`| Inspector gaps | ${summary.summary?.inspectorGaps ?? 0} |`,
"",
"This job is informational; Plugin Prerelease blocking status is unchanged.",
);
await writeFile(path.join(artifactRoot, "advisory-summary.md"), `${lines.join("\n")}\n`, "utf8");
if (existsSync(markdownPath)) {
lines.push("", "### Full inspector summary", "");
lines.push(await readFile(markdownPath, "utf8"));
}
} else {
lines.push("", "No plugin-inspector CI summary was produced.", "");
lines.push("This job is informational; inspect the uploaded stdout/stderr artifacts.");
await writeFile(path.join(artifactRoot, "advisory-summary.md"), `${lines.join("\n")}\n`, "utf8");
}
await appendFile(process.env.GITHUB_STEP_SUMMARY, `${lines.join("\n")}\n`, "utf8");
EOF
- name: Upload plugin inspector advisory artifacts
if: always()
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
with:
name: plugin-inspector-advisory
path: .artifacts/plugin-inspector/**
if-no-files-found: warn
plugin-prerelease-docker-suite:
name: plugin-prerelease-docker-suite
needs: [resolve-candidate, preflight]
if: ${{ inputs.full_release_validation && needs.preflight.outputs.run_plugin_prerelease_docker == 'true' }}
permissions:
actions: read
contents: read
packages: read
pull-requests: read
uses: ./.github/workflows/openclaw-live-and-e2e-checks-reusable.yml
with:
ref: ${{ needs.resolve-candidate.outputs.checkout_revision }}
include_repo_e2e: false
include_release_path_suites: false
include_openwebui: false
docker_lanes: ${{ needs.preflight.outputs.plugin_prerelease_docker_lanes }}
targeted_docker_lane_group_size: 2
allow_unreleased_changelog: true
include_live_suites: false
live_models_only: false
shared_image_artifact_namespace: plugin-prerelease
package_artifact_name: ${{ fromJSON(inputs.candidate_artifact_json || '{}').packageArtifactName || '' }}
package_artifact_id: ${{ fromJSON(inputs.candidate_artifact_json || '{}').packageArtifactId || '' }}
package_artifact_digest: ${{ fromJSON(inputs.candidate_artifact_json || '{}').packageArtifactDigest || '' }}
package_artifact_run_id: ${{ fromJSON(inputs.candidate_artifact_json || '{}').packageArtifactRunId || '' }}
package_artifact_run_attempt: ${{ fromJSON(inputs.candidate_artifact_json || '{}').packageArtifactRunAttempt || '' }}
package_file_name: ${{ fromJSON(inputs.candidate_artifact_json || '{}').packageFileName || '' }}
package_source_sha: ${{ fromJSON(inputs.candidate_artifact_json || '{}').packageSourceSha || '' }}
package_sha256: ${{ fromJSON(inputs.candidate_artifact_json || '{}').packageSha256 || '' }}
package_version: ${{ fromJSON(inputs.candidate_artifact_json || '{}').packageVersion || '' }}
enable_prepublish_plugin_registry: true
prepublish_plugin_registry_artifact_name: ${{ fromJSON(inputs.candidate_artifact_json || '{}').prepublishPluginRegistryArtifactName || '' }}
prepublish_plugin_registry_artifact_id: ${{ fromJSON(inputs.candidate_artifact_json || '{}').prepublishPluginRegistryArtifactId || '' }}
prepublish_plugin_registry_artifact_digest: ${{ fromJSON(inputs.candidate_artifact_json || '{}').prepublishPluginRegistryArtifactDigest || '' }}
prepublish_plugin_registry_artifact_run_id: ${{ fromJSON(inputs.candidate_artifact_json || '{}').prepublishPluginRegistryArtifactRunId || '' }}
prepublish_plugin_registry_artifact_run_attempt: ${{ fromJSON(inputs.candidate_artifact_json || '{}').prepublishPluginRegistryArtifactRunAttempt || '' }}
prepublish_plugin_registry_manifest_sha256: ${{ fromJSON(inputs.candidate_artifact_json || '{}').prepublishPluginRegistryManifestSha256 || '' }}
shared_image_artifact_name: ${{ fromJSON(inputs.candidate_artifact_json || '{}').imageArtifactName || '' }}
shared_image_artifact_id: ${{ fromJSON(inputs.candidate_artifact_json || '{}').imageArtifactId || '' }}
shared_image_artifact_digest: ${{ fromJSON(inputs.candidate_artifact_json || '{}').imageArtifactDigest || '' }}
shared_image_artifact_run_id: ${{ fromJSON(inputs.candidate_artifact_json || '{}').imageArtifactRunId || '' }}
shared_image_artifact_run_attempt: ${{ fromJSON(inputs.candidate_artifact_json || '{}').imageArtifactRunAttempt || '' }}
shared_image_archive_sha256: ${{ fromJSON(inputs.candidate_artifact_json || '{}').imageArchiveSha256 || '' }}
shared_image_policy: no-push-artifact
plugin-prerelease-suite:
permissions:
contents: read
name: plugin-prerelease-suite
needs:
- resolve-candidate
- preflight
- plugin-npm-security-scan
- plugin-prerelease-static-shard
- plugin-prerelease-node-shard
- plugin-prerelease-extension-shard
- plugin-prerelease-inspector
- plugin-prerelease-docker-suite
if: ${{ !cancelled() && always() && needs.preflight.outputs.run_plugin_prerelease_suite == 'true' }}
runs-on: ubuntu-24.04
timeout-minutes: 5
steps:
- name: Verify plugin prerelease suite
env:
RUN_STATIC: ${{ needs.preflight.outputs.run_plugin_prerelease_static }}
RUN_NODE: ${{ needs.preflight.outputs.run_plugin_prerelease_node }}
RUN_EXTENSIONS: ${{ needs.preflight.outputs.run_plugin_prerelease_extensions }}
RUN_DOCKER: ${{ needs.preflight.outputs.run_plugin_prerelease_docker }}
SECURITY_RESULT: ${{ needs.plugin-npm-security-scan.result }}
STATIC_RESULT: ${{ needs.plugin-prerelease-static-shard.result }}
NODE_RESULT: ${{ needs.plugin-prerelease-node-shard.result }}
EXTENSIONS_RESULT: ${{ needs.plugin-prerelease-extension-shard.result }}
INSPECTOR_RESULT: ${{ needs.plugin-prerelease-inspector.result }}
DOCKER_RESULT: ${{ needs.plugin-prerelease-docker-suite.result }}
shell: bash
run: |
set -euo pipefail
failed=0
check_required() {
local name="$1"
local required="$2"
local status="$3"
if [ "$required" != "true" ]; then
return 0
fi
if [ "$status" != "success" ]; then
echo "::error::${name} ended with ${status}"
failed=1
fi
}
check_required "plugin-npm-security-scan" "true" "$SECURITY_RESULT"
check_required "plugin-prerelease-static" "$RUN_STATIC" "$STATIC_RESULT"
check_required "plugin-prerelease-node" "$RUN_NODE" "$NODE_RESULT"
check_required "plugin-prerelease-extensions" "$RUN_EXTENSIONS" "$EXTENSIONS_RESULT"
check_required "plugin-prerelease-docker" "$RUN_DOCKER" "$DOCKER_RESULT"
echo "plugin-prerelease-inspector advisory result: ${INSPECTOR_RESULT}"
exit "$failed"