openclaw/.github/workflows/plugin-prerelease.yml
Peter Steinberger 8bd724e8a9
fix: honor exact frozen-target exclusions in release validation (#156300)
* fix(ci): honor exact frozen-target test exclusions

Bind plugin and extension omissions to the release request, discover their selected Vitest leaves, and preserve canonical candidate runners with reversible config overlays. Carry extension controls in the existing dispatch envelope to respect the hosted input limit. Remove the implicit Codex omission; preserve historical source receipts. Related #156253.

* fix(ci): model frozen exclusion runtime consumers

Expose real inline workflow and generated config imports to Knip while preserving the strict entry-export audit. Keep the overlay helper private and prove restoration and child exit behavior through the actual CLI. Related #156253.

* fix(ci): handle absent frozen exclusion import captures

* fix(ci): retain command failures during exclusion cleanup

Restore every owned config after capturing the command outcome, and preserve the primary error or exit status alongside restoration failures. Prove the CLI conflict path keeps concurrent edits and the original backup while restoring other overlays. Resolve the focused helper lint findings without suppressions. Related #156253.

* test(ci): stabilize release evidence and timeout fixtures

Match the release writer fixture to the declared empty exclusion list while retaining strict source binding. Drive the stalled Discord body timeout with a controlled clock so host scheduling cannot consume the total deadline before body admission. Preserve the original timeout and assertion. Related #156253.
2026-09-23 01:51:51 -07:00

1088 lines
48 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
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: plugin-prerelease-${{ inputs.target_ref }}-${{ github.sha }}-${{ inputs.phase }}
cancel-in-progress: ${{ inputs.target_ref == 'main' }}
env:
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true"
jobs:
resolve_target:
name: Resolve target ref
runs-on: 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: ${{ inputs.target_ref }}
EXPECTED_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: 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: ${{ 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.19.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: ${{ inputs.node_test_exclude_patterns_json }}
EXTENSION_TEST_EXCLUDE_PATTERNS_JSON: ${{ 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: ${{ 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 phase = process.env.PHASE ?? "all";
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 planner = await import(targetPlanPath("extension-test-plan"));
const allExtensionShards = planner.createExtensionTestShards({
shardCount: planner.DEFAULT_EXTENSION_TEST_SHARD_COUNT,
});
const hasJobSplitter = typeof planner.splitExtensionTestJobTargets === "function";
const telegramPlanGroups = hasJobSplitter
? planner.createExtensionTestShards({ extensionIds: ["telegram"], shardCount: 1 })
.flatMap((shard) => shard.planGroups)
: [];
const genericExtensionIds = hasJobSplitter
? allExtensionShards
.flatMap((shard) => shard.extensionIds)
.filter((extensionId) => extensionId !== "telegram")
: [];
const batchShards = (
hasJobSplitter
? planner.createExtensionTestShards({
extensionIds: genericExtensionIds,
shardCount: planner.DEFAULT_EXTENSION_TEST_SHARD_COUNT,
})
: allExtensionShards
).map((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",
}));
const telegramShards = [];
for (const group of telegramPlanGroups) {
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-telegram-shard-${index + 1}`,
extensions_csv: "telegram",
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",
});
}
}
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 && staticChecks.length > 0;
const runNode = runIndependent && nodeShards.length > 0;
const runExtensions = runIndependent && extensionShards.length > 0;
const runInspector = runIndependent && (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: inputs.phase != 'candidate'
runs-on: ubuntu-24.04
timeout-minutes: 10
outputs:
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: Setup trusted scanner TypeScript runtime
uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6.5.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@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
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: ubuntu-24.04
timeout-minutes: 45
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@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6.5.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@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
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: ${{ 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: ${{ 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: ${{ 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"
- *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 }}
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[@]}" -- --retry=1 "${exclude_args[@]}"
;;
extension-file-shard)
include_file="${RUNNER_TEMP}/telegram-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 Telegram 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: 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:
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: 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: ${{ 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"