Skip to content

Hourly Extension Tests #94

Hourly Extension Tests

Hourly Extension Tests #94

name: Plugin Prerelease
run-name: ${{ github.event_name == 'schedule' && 'Hourly Extension Tests' || inputs.dispatch_id != '' && format('Plugin Prerelease {0}', inputs.dispatch_id) || 'Plugin Prerelease' }}
on:
schedule:
- cron: "37 * * * *"
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
target_context_ref:
description: Canonical release context for an exact-SHA frozen-target validation
required: false
default: ""
type: string
allow_frozen_target_scenario_omissions:
description: Trusted opt-in to accept documented harness differences in a frozen target
required: false
default: false
type: boolean
full_release_validation:
description: Enable release-only Docker prerelease lanes from Full Release Validation
required: false
default: false
type: boolean
phase:
description: Plugin prerelease phase to run
required: false
default: all
type: choice
options:
- all
- independent
- candidate
node_test_exclude_patterns_json:
description: Full Release Validation-only exact plugin test paths omitted for a frozen target
required: false
default: "[]"
type: string
extension_test_exclude_patterns_json:
description: Full Release Validation-only exact extension test paths omitted for a frozen target
required: false
default: "[]"
type: string
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: ${{ github.event_name == 'schedule' && 'plugin-prerelease-hourly-main' || format('plugin-prerelease-{0}-{1}-{2}', inputs.target_ref, github.sha, inputs.phase) }}
cancel-in-progress: ${{ github.event_name != 'schedule' && inputs.target_ref == 'main' }}
env:
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true"
jobs:
resolve_target:
name: Resolve target ref
if: github.event_name != 'schedule' || (github.repository == 'openclaw/openclaw' && github.ref == 'refs/heads/main')
runs-on: ${{ vars.OPENCLAW_RELEASE_RUNNER_GROUP != '' && fromJSON(format('{{"group":{0},"labels":{1}}}', toJSON(vars.OPENCLAW_RELEASE_RUNNER_GROUP), toJSON('ubuntu-24.04'))) || ('ubuntu-24.04') }}
timeout-minutes: 5
outputs:
checkout_revision: ${{ steps.resolve.outputs.sha }}
steps:
# Candidate planning executes repository code; admission must own a separate runner.
- name: Checkout trusted ref resolver
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
ref: ${{ github.sha }}
path: workflow
sparse-checkout: scripts/github/resolve-openclaw-ref.sh
sparse-checkout-cone-mode: false
fetch-depth: 1
persist-credentials: false
submodules: false
- name: Resolve target SHA
id: resolve
env:
TARGET_REF: ${{ github.event_name == 'schedule' && github.sha || inputs.target_ref }}
EXPECTED_SHA: ${{ github.event_name == 'schedule' && github.sha || inputs.expected_sha }}
OPENCLAW_REF_REMOTE: ${{ github.server_url }}/${{ github.repository }}.git
run: |
bash workflow/scripts/github/resolve-openclaw-ref.sh \
--ref "$TARGET_REF" \
--expected-sha "$EXPECTED_SHA" \
--github-output "$GITHUB_OUTPUT"
preflight:
name: Build plugin prerelease plan
needs: [resolve_target]
runs-on: ${{ vars.OPENCLAW_RELEASE_RUNNER_GROUP != '' && fromJSON(format('{{"group":{0},"labels":{1}}}', toJSON(vars.OPENCLAW_RELEASE_RUNNER_GROUP), toJSON('ubuntu-24.04'))) || ('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 }}
node_test_exclude_patterns_json: ${{ steps.node_test_exclusions.outputs.patterns_json }}
extension_test_exclude_patterns_json: ${{ steps.node_test_exclusions.outputs.extension_patterns_json }}
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_inspector: ${{ steps.manifest.outputs.run_plugin_prerelease_inspector }}
run_plugin_prerelease_docker: ${{ steps.manifest.outputs.run_plugin_prerelease_docker }}
plugin_prerelease_docker_lanes: ${{ steps.manifest.outputs.plugin_prerelease_docker_lanes }}
steps:
- name: Validate phase inputs
env:
CANDIDATE_ARTIFACT_JSON: ${{ inputs.candidate_artifact_json }}
EXPECTED_SHA: ${{ inputs.expected_sha }}
FULL_RELEASE_VALIDATION: ${{ inputs.full_release_validation && 'true' || 'false' }}
PHASE: ${{ github.event_name == 'schedule' && 'independent' || inputs.phase }}
shell: bash
run: |
set -euo pipefail
case "$PHASE" in
all|independent) exit 0 ;;
candidate) ;;
*)
echo "phase must be one of: all, independent, candidate" >&2
exit 1
;;
esac
if [[ "$FULL_RELEASE_VALIDATION" != "true" ]]; then
echo "phase=candidate requires full_release_validation=true." >&2
exit 1
fi
if ! jq -e \
--arg sha "$EXPECTED_SHA" \
'def digits: tostring | test("^[1-9][0-9]*$");
def hex40: type == "string" and test("^[a-f0-9]{40}$");
def hex64: type == "string" and test("^[a-f0-9]{64}$");
(.packageArtifactName | type == "string" and length > 0) and
(.packageArtifactId | digits) and
(.packageArtifactDigest | hex64) and
(.packageArtifactRunId | digits) and
(.packageArtifactRunAttempt | digits) and
(.packageFileName | type == "string" and test("^[A-Za-z0-9][A-Za-z0-9._-]*\\.tgz$")) and
(.packageSourceSha | hex40) and
($sha == "" or .packageSourceSha == $sha) and
(.packageSha256 | hex64) and
(.packageVersion | type == "string" and length > 0) and
(.imageArtifactName | type == "string" and length > 0) and
(.imageArtifactId | digits) and
(.imageArtifactDigest | hex64) and
(.imageArtifactRunId | digits) and
(.imageArtifactRunAttempt | digits) and
(.imageArchiveSha256 | hex64)' \
<<< "$CANDIDATE_ARTIFACT_JSON" >/dev/null; then
echo "phase=candidate requires the complete immutable package and Docker image artifact tuple." >&2
exit 1
fi
- name: Setup supported Node runtime
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
with:
node-version: "24.21.0"
package-manager-cache: false
- name: Checkout target
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
ref: ${{ needs.resolve_target.outputs.checkout_revision }}
fetch-depth: 1
fetch-tags: false
persist-credentials: false
submodules: false
- &checkout_exclusion_tooling
name: Checkout trusted test exclusion tooling
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
ref: ${{ github.workflow_sha }}
path: .frv-tooling
sparse-checkout: scripts/frv-test-exclusions.mjs
sparse-checkout-cone-mode: false
persist-credentials: false
- name: Validate frozen-target Node exclusions
id: node_test_exclusions
env:
FULL_RELEASE_VALIDATION: ${{ inputs.full_release_validation && 'true' || 'false' }}
NODE_TEST_EXCLUDE_PATTERNS_JSON: ${{ github.event_name == 'schedule' && '[]' || inputs.node_test_exclude_patterns_json }}
EXTENSION_TEST_EXCLUDE_PATTERNS_JSON: ${{ github.event_name == 'schedule' && '[]' || inputs.extension_test_exclude_patterns_json }}
run: |
node --input-type=module <<'EOF'
import { appendFileSync } from "node:fs";
import { parseTestExclusions } from "./.frv-tooling/scripts/frv-test-exclusions.mjs";
const patterns = parseTestExclusions(process.env.NODE_TEST_EXCLUDE_PATTERNS_JSON, "plugins");
const extensions = parseTestExclusions(process.env.EXTENSION_TEST_EXCLUDE_PATTERNS_JSON, "extensions");
if (process.env.FULL_RELEASE_VALIDATION !== "true" && (patterns.length || extensions.length)) {
throw new Error("Test exclusions require full_release_validation=true");
}
appendFileSync(process.env.GITHUB_OUTPUT,
`patterns_json=${JSON.stringify(patterns)}\nextension_patterns_json=${JSON.stringify(extensions)}\n`);
EOF
- name: Setup manifest TypeScript runtime
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
with:
node-version: "24.x"
- name: Setup manifest pnpm
uses: ./.github/actions/setup-pnpm-store-cache
with:
cache-mode: restore
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' }}
PHASE: ${{ github.event_name == 'schedule' && 'independent' || inputs.phase }}
NODE_TEST_EXCLUDE_PATTERNS_JSON: ${{ steps.node_test_exclusions.outputs.patterns_json }}
EXTENSION_TEST_EXCLUDE_PATTERNS_JSON: ${{ steps.node_test_exclusions.outputs.extension_patterns_json }}
run: |
node --import tsx --input-type=module <<'EOF'
import { appendFileSync, existsSync, globSync } from "node:fs";
import path from "node:path";
const createMatrix = (include) => ({ include });
const outputPath = process.env.GITHUB_OUTPUT;
const fullReleaseValidation = process.env.FULL_RELEASE_VALIDATION === "true";
const hourly = process.env.GITHUB_EVENT_NAME === "schedule";
const phase = process.env.PHASE ?? "all";
let pluginPrereleasePlan = { staticChecks: [], dockerLanes: [] };
let resolveExtensionRuntime = () => ({ test_runtime_policy: "node", requires_bun: false });
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",
"bundled-plugin-source-utils": "./scripts/lib/bundled-plugin-source-utils.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 planner = await import(
targetPlanPath("plugin-prerelease-test-plan")
);
pluginPrereleasePlan = planner.assertPluginPrereleaseTestPlanComplete();
if (typeof planner.resolvePluginPrereleaseExtensionRuntime === "function") {
resolveExtensionRuntime = planner.resolvePluginPrereleaseExtensionRuntime;
}
} 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 planner = await import(targetPlanPath("extension-test-plan"));
const extensionVitestArgs = JSON.parse(
process.env.EXTENSION_TEST_EXCLUDE_PATTERNS_JSON || "[]",
).map((file) => `--exclude=${file}`);
const allExtensionShards = planner.createExtensionTestShards({
shardCount: planner.DEFAULT_EXTENSION_TEST_SHARD_COUNT,
});
const batchedExtensionIds = new Set(allExtensionShards.flatMap((shard) => shard.extensionIds));
const hasJobSplitter = typeof planner.splitExtensionTestJobTargets === "function";
// Codex alone took 36-60 min on hosted release runners (FRV 36644909314 timed out),
// so it gets file-bounded jobs like Telegram instead of one batch job.
const fileSplitExtensionIds = ["telegram", "codex"].filter((id) => batchedExtensionIds.has(id));
const fileSplitPlanGroups = hasJobSplitter
? await Promise.all(fileSplitExtensionIds.map(async (extensionId) => {
const planGroups = planner.createExtensionTestShards({ extensionIds: [extensionId], shardCount: 1 })
.flatMap((shard) => shard.planGroups);
// Telegram rows keep their historical Node-only runtime.
const runtime = extensionId === "telegram"
? {}
: await resolveExtensionRuntime({ planGroups, fullReleaseValidation, vitestArgs: extensionVitestArgs });
return planGroups.map((group) => ({ ...group, family: extensionId, runtime }));
})).then((groups) => groups.flat())
: [];
const sourcePlanGroups = new Map();
if (hasJobSplitter) {
const sources = await import(targetPlanPath("bundled-plugin-source-utils"));
// August frozen targets expose the public collector but not its tracked projection.
const candidates = typeof sources.collectTrackedBundledPluginSourceCandidates === "function"
? sources.collectTrackedBundledPluginSourceCandidates(process.cwd())
: sources.collectBundledPluginSources({ repoRoot: process.cwd() });
if (!candidates) throw new Error("Cannot enumerate bundled plugin source metadata");
// Older planners omit manifest-only plugins; supplement only their missing owners.
const sourceRoots = candidates.filter((entry) => entry.manifestPath && !entry.packageJsonPath && !batchedExtensionIds.has(entry.dirName))
.map((entry) => `extensions/${entry.dirName}`);
const sourceFiles = [
...planner.listExtensionTestFilesForRoots(sourceRoots),
...planner.listExtensionTestFilesForRoots(["extensions"])
.filter((file) => path.posix.dirname(file) === "extensions"),
];
for (const file of sourceFiles) {
const config = planner.resolveExtensionTestConfig(file);
const group = sourcePlanGroups.get(config) ?? { config, roots: [] };
group.roots.push(file);
sourcePlanGroups.set(config, group);
}
}
const genericExtensionIds = hasJobSplitter
? allExtensionShards
.flatMap((shard) => shard.extensionIds)
.filter((extensionId) => !fileSplitExtensionIds.includes(extensionId))
: [];
const batchShards = await Promise.all((
hasJobSplitter
? planner.createExtensionTestShards({
extensionIds: genericExtensionIds,
shardCount: planner.DEFAULT_EXTENSION_TEST_SHARD_COUNT,
})
: allExtensionShards
).map(async (shard) => ({
check_name: shard.checkName,
extensions_csv: shard.extensionIds.join(","),
exclusion_configs: shard.planGroups.map((group) => ({
config: group.config,
includePatterns: group.roots.map((root) => root.endsWith(".test.ts") ? root : `${root}/**/*.test.ts`),
})),
vitest_config: "",
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",
...await resolveExtensionRuntime({
planGroups: shard.planGroups,
fullReleaseValidation,
vitestArgs: extensionVitestArgs,
}),
})));
const telegramShards = [];
for (const group of [
...fileSplitPlanGroups,
...[...sourcePlanGroups.values()].map((group) => ({ ...group, family: "source" })),
]) {
const vitestConfig = (await import(`./${group.config}`)).default;
const testConfig = vitestConfig.test ?? {};
const testDir = testConfig.dir ?? process.cwd();
const exclude = (testConfig.exclude ?? []).map((pattern) =>
path.isAbsolute(pattern)
? path.relative(testDir, pattern).replaceAll("\\", "/")
: pattern,
);
const testFiles = globSync(testConfig.include ?? [], { cwd: testDir, exclude })
.map((file) =>
path.relative(process.cwd(), path.resolve(testDir, file)).replaceAll("\\", "/"),
)
.filter((file) =>
group.roots.some((root) => file === root || file.startsWith(`${root}/`)),
)
.sort();
const chunks = planner.splitExtensionTestJobTargets(group.config, testFiles);
for (const includePatterns of chunks) {
if (includePatterns.length === 0) {
continue;
}
const index = telegramShards.length;
telegramShards.push({
check_name: `checks-node-extensions-${group.family}-shard-${index + 1}`,
extensions_csv: [...new Set(includePatterns.map((file) => file.split("/")[1]))].join(","),
includePatterns,
vitest_config: group.config,
exclusion_configs: [{ config: group.config, includePatterns }],
vitest_max_workers: 1,
runner: "blacksmith-8vcpu-ubuntu-2404",
shard_index: planner.DEFAULT_EXTENSION_TEST_SHARD_COUNT + index + 1,
task: "extension-file-shard",
...group.runtime,
});
}
}
extensionShards = [...batchShards, ...telegramShards];
} 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 runIndependent = phase !== "candidate";
const runCandidate = phase !== "independent";
const runStatic = runIndependent && !hourly && staticChecks.length > 0;
const runNode = runIndependent && !hourly && nodeShards.length > 0;
const runExtensions = runIndependent && extensionShards.length > 0;
if (hourly && !runExtensions) {
throw new Error("Hourly plugin validation requires the complete extension test inventory");
}
const runInspector = runIndependent && !hourly && (runStatic || runNode || runExtensions);
if (runIndependent && ((process.env.NODE_TEST_EXCLUDE_PATTERNS_JSON || "[]") !== "[]" || (process.env.EXTENSION_TEST_EXCLUDE_PATTERNS_JSON || "[]") !== "[]")) {
const { parseTestExclusions, validateTestExclusions } = await import("./.frv-tooling/scripts/frv-test-exclusions.mjs");
await validateTestExclusions({
paths: parseTestExclusions(process.env.NODE_TEST_EXCLUDE_PATTERNS_JSON, "plugins"),
configs: nodeShards.flatMap((row) => row.configs.map((config) => ({ config, includePatterns: row.includePatterns }))),
});
await validateTestExclusions({
paths: parseTestExclusions(process.env.EXTENSION_TEST_EXCLUDE_PATTERNS_JSON, "extensions"),
configs: extensionShards.flatMap((row) => row.exclusion_configs),
});
}
const runDocker = runCandidate && fullReleaseValidation && dockerLanes.length > 0;
const runSuite = runStatic || runNode || runExtensions || runInspector || 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_inspector: runInspector,
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_target]
if: github.event_name != 'schedule' && inputs.phase != 'candidate'
runs-on: ${{ vars.OPENCLAW_RELEASE_RUNNER_GROUP != '' && fromJSON(format('{{"group":{0},"labels":{1}}}', toJSON(vars.OPENCLAW_RELEASE_RUNNER_GROUP), toJSON('ubuntu-24.04'))) || ('ubuntu-24.04') }}
timeout-minutes: 10
outputs:
packages_json: ${{ steps.plan.outputs.packages_json }}
steps:
- name: Checkout trusted scanner tooling
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
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@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
with:
node-version: "24.x"
- name: Setup trusted scanner pnpm
uses: ./.github/actions/setup-pnpm-store-cache
with:
cache-mode: restore
node-version: "24.x"
- name: Install trusted scanner dependencies
run: pnpm install --frozen-lockfile --prefer-offline --ignore-scripts
- name: Checkout candidate as inert data
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
ref: ${{ needs.resolve_target.outputs.checkout_revision }}
path: .release-candidate
fetch-depth: 1
fetch-tags: false
persist-credentials: false
submodules: false
- 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-scan:
permissions:
contents: read
name: plugin-npm-security-scan
needs: [resolve_target, plugin-npm-security-plan]
if: ${{ !cancelled() && always() && needs.resolve_target.result == 'success' && needs.plugin-npm-security-plan.result == 'success' }}
runs-on: ${{ vars.OPENCLAW_RELEASE_RUNNER_GROUP != '' && fromJSON(format('{{"group":{0},"labels":{1}}}', toJSON(vars.OPENCLAW_RELEASE_RUNNER_GROUP), toJSON('ubuntu-24.04'))) || ('ubuntu-24.04') }}
timeout-minutes: 45
steps:
- name: Checkout trusted scanner tooling
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
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@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
with:
node-version: "24.x"
- name: Setup trusted scanner pnpm
uses: ./.github/actions/setup-pnpm-store-cache
with:
cache-mode: restore
node-version: "24.x"
- name: Install trusted scanner dependencies
run: pnpm install --frozen-lockfile --prefer-offline --ignore-scripts
- name: Checkout candidate as inert package input
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
ref: ${{ needs.resolve_target.outputs.checkout_revision }}
path: .release-candidate
fetch-depth: 1
fetch-tags: false
persist-credentials: false
submodules: false
- name: Pack supplemental inert plugin inputs
env:
CANDIDATE_SHA: ${{ needs.resolve_target.outputs.checkout_revision }}
EXPECTED_PACKAGES_JSON: ${{ needs.plugin-npm-security-plan.outputs.packages_json }}
TOOLING_SHA: ${{ github.sha }}
shell: bash
run: |
set -euo pipefail
artifact_root="$RUNNER_TEMP/plugin-npm-security-packages"
mkdir -p "$artifact_root"
while IFS=$'\t' read -r extension_id package_dir package_name; do
output_dir="$artifact_root/plugin-npm-security-package-${CANDIDATE_SHA}-${extension_id}"
if ! 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"; then
printf 'Package preparation failed for %s\n' "$package_name" >&2
fi
done < <(jq -r '.[] | [.extensionId, .packageDir, .packageName] | @tsv' <<< "$EXPECTED_PACKAGES_JSON")
- name: Scan supplemental inert plugin inputs
env:
CANDIDATE_SHA: ${{ needs.resolve_target.outputs.checkout_revision }}
EXPECTED_PACKAGES_JSON: ${{ needs.plugin-npm-security-plan.outputs.packages_json }}
TARGET_CONTEXT_REF: ${{ inputs.target_context_ref }}
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"
status=0
node scripts/plugin-npm-security-scan-runner.mjs \
--artifact-root "$RUNNER_TEMP/plugin-npm-security-packages" \
--candidate-sha "$CANDIDATE_SHA" \
--expected-packages-json "$EXPECTED_PACKAGES_JSON" \
--target-context-ref "$TARGET_CONTEXT_REF" \
--tooling-sha "$TOOLING_SHA" \
--report "$report" || status=$?
exit "$status"
- 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
# Job-level skips happen before matrix expansion; retain a unique owner name.
name: ${{ matrix.check_name || 'plugin-prerelease-static-shard' }}
needs: [resolve_target, preflight]
if: needs.preflight.outputs.run_plugin_prerelease_static == 'true'
runs-on: ${{ vars.OPENCLAW_RELEASE_RUNNER_GROUP != '' && fromJSON(format('{{"group":{0},"labels":{1}}}', toJSON(vars.OPENCLAW_RELEASE_RUNNER_GROUP), toJSON(github.event_name == 'workflow_dispatch' && 'ubuntu-24.04' || 'blacksmith-8vcpu-ubuntu-2404'))) || (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@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
ref: ${{ needs.resolve_target.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:
cache-mode: restore
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 || 'plugin-prerelease-node-shard' }}
needs: [resolve_target, preflight]
if: needs.preflight.outputs.run_plugin_prerelease_node == 'true'
runs-on: ${{ vars.OPENCLAW_RELEASE_RUNNER_GROUP != '' && fromJSON(format('{{"group":{0},"labels":{1}}}', toJSON(vars.OPENCLAW_RELEASE_RUNNER_GROUP), toJSON(github.event_name == 'workflow_dispatch' && 'ubuntu-24.04' || (matrix.runner || 'ubuntu-24.04')))) || (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@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
ref: ${{ needs.resolve_target.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:
cache-mode: restore
install-bun: "false"
- name: Configure Node test resources
run: echo "OPENCLAW_VITEST_MAX_WORKERS=2" >> "$GITHUB_ENV"
- *checkout_exclusion_tooling
- name: Run release-only plugin Node shard
env:
NODE_OPTIONS: --max-old-space-size=8192
NODE_TEST_EXCLUDE_PATTERNS_JSON: ${{ needs.preflight.outputs.node_test_exclude_patterns_json }}
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 excludePatterns = JSON.parse(
process.env.NODE_TEST_EXCLUDE_PATTERNS_JSON ?? "[]",
);
if (
!Array.isArray(excludePatterns) ||
excludePatterns.some((pattern) => typeof pattern !== "string")
) {
console.error("Invalid frozen-target Node exclusions");
process.exit(1);
}
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;
}
childEnv.FRV_TEST_EXCLUDE_PATHS_JSON = JSON.stringify(excludePatterns);
childEnv.FRV_TEST_EXCLUDE_SCOPE = "plugins";
childEnv.FRV_TEST_CONFIGS_JSON = JSON.stringify(configs);
const result = spawnSync(process.execPath, [".frv-tooling/scripts/frv-test-exclusions.mjs", "run", "--", "pnpm", "test", "--", ...configs], {
env: childEnv,
stdio: "inherit",
});
process.exit(result.status ?? 1);
EOF
plugin-prerelease-extension-shard:
permissions:
contents: read
name: ${{ matrix.check_name || 'plugin-prerelease-extension-shard' }}
needs: [resolve_target, preflight]
if: needs.preflight.outputs.run_plugin_prerelease_extensions == 'true'
runs-on: ${{ vars.OPENCLAW_RELEASE_RUNNER_GROUP != '' && fromJSON(format('{{"group":{0},"labels":{1}}}', toJSON(vars.OPENCLAW_RELEASE_RUNNER_GROUP), toJSON(github.event_name == 'workflow_dispatch' && 'ubuntu-24.04' || matrix.runner))) || (github.event_name == 'workflow_dispatch' && 'ubuntu-24.04' || matrix.runner) }}
timeout-minutes: 60
strategy:
fail-fast: false
max-parallel: 12
matrix: ${{ fromJson(needs.preflight.outputs.plugin_prerelease_extension_matrix) }}
steps:
- name: Checkout
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
ref: ${{ needs.resolve_target.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:
cache-mode: restore
install-bun: "false"
- name: Setup pinned Bun test runtime
if: matrix.requires_bun == true
uses: ./.github/actions/setup-test-bun
- *checkout_exclusion_tooling
- 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 }}
OPENCLAW_EXTENSION_INCLUDE_PATTERNS_JSON: ${{ toJson(matrix.includePatterns) }}
OPENCLAW_EXTENSION_TASK: ${{ matrix.task }}
OPENCLAW_CI_TEST_RUNTIME_POLICY: ${{ matrix.test_runtime_policy || 'node' }}
FRV_TEST_EXCLUDE_PATHS_JSON: ${{ needs.preflight.outputs.extension_test_exclude_patterns_json }}
FRV_TEST_EXCLUDE_SCOPE: extensions
FRV_TEST_CONFIGS_JSON: ${{ toJson(matrix.exclusion_configs) }}
OPENCLAW_EXTENSION_VITEST_CONFIG: ${{ matrix.vitest_config }}
shell: bash
run: |
set -euo pipefail
case "$OPENCLAW_EXTENSION_TASK" in
extensions-batch)
# The batch planner also uses exclusions when composing process targets.
mapfile -t exclude_args < <(jq -r '.[] | "--exclude=" + .' <<< "$FRV_TEST_EXCLUDE_PATHS_JSON")
exclusion_options=()
if (( ${#exclude_args[@]} > 0 )); then exclusion_options=(--allow-empty-after-exclude); fi
node .frv-tooling/scripts/frv-test-exclusions.mjs run -- pnpm test:extensions:batch "$OPENCLAW_EXTENSION_BATCH" "${exclusion_options[@]}" -- "${exclude_args[@]}"
;;
extension-file-shard)
include_file="${RUNNER_TEMP}/extension-test-include-${GITHUB_JOB}.json"
trap 'rm -f -- "$include_file"' EXIT
INCLUDE_FILE="$include_file" node --input-type=module <<'EOF'
import { writeFileSync } from "node:fs";
const patterns = JSON.parse(process.env.OPENCLAW_EXTENSION_INCLUDE_PATTERNS_JSON);
if (!Array.isArray(patterns) || patterns.length === 0) {
throw new Error("Invalid extension file shard");
}
writeFileSync(process.env.INCLUDE_FILE, JSON.stringify(patterns), "utf8");
EOF
OPENCLAW_TEST_PROJECTS_PARALLEL=2 \
OPENCLAW_VITEST_INCLUDE_FILE="$include_file" \
node .frv-tooling/scripts/frv-test-exclusions.mjs run -- pnpm test -- "$OPENCLAW_EXTENSION_VITEST_CONFIG"
trap - EXIT
rm -f -- "$include_file"
;;
*)
echo "Unknown extension test task: $OPENCLAW_EXTENSION_TASK" >&2
exit 1
;;
esac
plugin-prerelease-inspector:
permissions:
contents: read
name: plugin-prerelease-inspector
needs: [resolve_target, preflight]
if: needs.preflight.outputs.run_plugin_prerelease_inspector == 'true'
continue-on-error: true
runs-on: ${{ vars.OPENCLAW_RELEASE_RUNNER_GROUP != '' && fromJSON(format('{{"group":{0},"labels":{1}}}', toJSON(vars.OPENCLAW_RELEASE_RUNNER_GROUP), toJSON('ubuntu-24.04'))) || ('ubuntu-24.04') }}
timeout-minutes: 30
steps:
- name: Checkout
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
ref: ${{ needs.resolve_target.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:
cache-mode: restore
install-bun: "false"
- name: Run plugin inspector advisory sweep
env:
OPENCLAW_PLUGIN_INSPECTOR_VERSION: "0.3.26"
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_target, 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:
runner_group: ${{ vars.OPENCLAW_RELEASE_RUNNER_GROUP }}
ref: ${{ needs.resolve_target.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
allow_frozen_target_scenario_omissions: ${{ inputs.allow_frozen_target_scenario_omissions }}
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:
- 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: ${{ vars.OPENCLAW_RELEASE_RUNNER_GROUP != '' && fromJSON(format('{{"group":{0},"labels":{1}}}', toJSON(vars.OPENCLAW_RELEASE_RUNNER_GROUP), toJSON('ubuntu-24.04'))) || ('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 }}
RUN_NPM_SECURITY: ${{ github.event_name != 'schedule' && inputs.phase != 'candidate' && 'true' || 'false' }}
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" "$RUN_NPM_SECURITY" "$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"
seal_release_child_evidence:
name: Seal full release child evidence
needs:
- resolve_target
- preflight
- plugin-npm-security-plan
- plugin-npm-security-scan
- plugin-prerelease-static-shard
- plugin-prerelease-node-shard
- plugin-prerelease-extension-shard
- plugin-prerelease-inspector
- plugin-prerelease-docker-suite
- plugin-prerelease-suite
if: ${{ always() && !cancelled() && github.event_name == 'workflow_dispatch' && startsWith(inputs.dispatch_id, 'full-release-validation-') }}
permissions:
contents: read
actions: read
uses: ./.github/workflows/full-release-child-evidence.yml
with:
role: ${{ inputs.phase == 'candidate' && 'pluginPrereleaseCandidate' || 'pluginPrereleaseIndependent' }}
target_sha: ${{ needs.resolve_target.outputs.checkout_revision }}