fix(ci): block release fanout on expired plugin compatibility (#155971)

* fix(ci): gate release fanout on plugin compatibility

* fix(ci): complete compatibility gate admission

* fix(ci): gate published release checks on compatibility
This commit is contained in:
Dallin Romney 2026-09-22 21:36:29 -07:00 • committed by GitHub
parent 1e18169ed8
commit a298fa9b67
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 310 additions and 39 deletions

View file

@ -203,6 +203,7 @@ jobs:
source_admission_json: ${{ steps.publication_admission.outputs.json }}
validation_purpose: ${{ steps.publication_request.outputs.validation_purpose }}
publication_selection_json: ${{ steps.publication_request.outputs.publication_selection_json }}
plugin_compatibility_required: ${{ steps.plugin_compatibility.outputs.required }}
steps:
- name: Setup supported Node runtime
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
@ -436,6 +437,26 @@ jobs:
persist-credentials: false
submodules: false
- name: Detect target plugin compatibility gate
id: plugin_compatibility
env:
GH_TOKEN: ${{ github.token }}
TARGET_SHA: ${{ steps.resolve.outputs.sha }}
run: |
set -euo pipefail
if jq -e '.scripts["plugins:boundary-report:ci"] | type == "string"' target/package.json >/dev/null; then
echo 'required=true' >> "$GITHUB_OUTPUT"
exit 0
fi
gate_introduction=38ba27834dd3f98c19d5833e0598dfef3abb7587
relationship="$(gh api "repos/${GITHUB_REPOSITORY}/compare/${gate_introduction}...${TARGET_SHA}" --jq .status)"
if [[ "$relationship" == "ahead" || "$relationship" == "identical" ]]; then
echo 'Current target is missing plugins:boundary-report:ci.' >&2
exit 1
fi
echo 'required=false' >> "$GITHUB_OUTPUT"
echo '::warning::Frozen target predates the plugin compatibility release gate; skipping target-owned compatibility readiness.'
- name: Validate release inputs
id: release_inputs
env:
@ -876,10 +897,53 @@ jobs:
fi
} >> "$GITHUB_STEP_SUMMARY"
plugin_compatibility_readiness:
name: Enforce plugin compatibility release readiness
needs: [resolve_target]
if: needs.resolve_target.outputs.plugin_compatibility_required == 'true'
runs-on: ${{ github.repository == 'openclaw/openclaw' && vars.OPENCLAW_CI_RUNNER_BACKEND == 'hybrid' && 'blacksmith-4vcpu-ubuntu-2404' || 'ubuntu-24.04' }}
timeout-minutes: 10
steps:
- name: Checkout target source
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
ref: ${{ needs.resolve_target.outputs.sha }}
fetch-depth: 1
filter: blob:none
persist-credentials: false
submodules: false
- name: Checkout trusted package-manager setup
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
ref: ${{ github.sha }}
path: .release-harness
sparse-checkout: .github/actions/setup-pnpm-store-cache
sparse-checkout-cone-mode: false
fetch-depth: 1
persist-credentials: false
submodules: false
- name: Setup target package manager
uses: ./.release-harness/.github/actions/setup-pnpm-store-cache
with:
package-manager-file: package.json
lockfile-path: pnpm-lock.yaml
node-version: ${{ env.NODE_VERSION }}
cache-mode: restore
- name: Install target dependencies
env:
CI: "true"
run: pnpm install --frozen-lockfile --prefer-offline --ignore-scripts
- name: Enforce target compatibility readiness
run: pnpm plugins:boundary-report:ci
evidence_reuse:
name: Check for reusable validation evidence
needs: [resolve_target]
if: inputs.rerun_group == 'all' && inputs.reuse_evidence && (github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/heads/release-ci/'))
needs: [resolve_target, plugin_compatibility_readiness]
if: ${{ always() && needs.resolve_target.result == 'success' && contains(fromJSON('["success","skipped"]'), needs.plugin_compatibility_readiness.result) && inputs.rerun_group == 'all' && inputs.reuse_evidence && (github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/heads/release-ci/')) }}
runs-on: ${{ github.repository == 'openclaw/openclaw' && vars.OPENCLAW_CI_RUNNER_BACKEND == 'hybrid' && 'blacksmith-4vcpu-ubuntu-2404' || 'ubuntu-24.04' }}
timeout-minutes: 10
outputs:
@ -1052,10 +1116,10 @@ jobs:
docker_runtime_assets_preflight:
name: Verify Docker runtime image assets
needs: [resolve_target, evidence_reuse]
needs: [resolve_target, plugin_compatibility_readiness, evidence_reuse]
# Release image preparation checks runtime-assets on both native architectures.
# Alpha has no release image producer, so it retains this standalone proof.
if: ${{ always() && github.run_attempt == 1 && needs.resolve_target.result == 'success' && contains(fromJSON('["success","skipped"]'), needs.evidence_reuse.result) && inputs.rerun_group == 'all' && contains(needs.resolve_target.outputs.target_version, '-alpha.') && needs.evidence_reuse.outputs.reuse != 'true' }}
if: ${{ always() && github.run_attempt == 1 && needs.resolve_target.result == 'success' && contains(fromJSON('["success","skipped"]'), needs.plugin_compatibility_readiness.result) && contains(fromJSON('["success","skipped"]'), needs.evidence_reuse.result) && inputs.rerun_group == 'all' && contains(needs.resolve_target.outputs.target_version, '-alpha.') && needs.evidence_reuse.outputs.reuse != 'true' }}
runs-on: ubuntu-24.04
timeout-minutes: 20
permissions:
@ -1080,8 +1144,8 @@ jobs:
normal_ci:
name: Run normal full CI
needs: [resolve_target, evidence_reuse]
if: ${{ always() && github.run_attempt == 1 && needs.resolve_target.result == 'success' && contains(fromJSON('["success","skipped"]'), needs.evidence_reuse.result) && contains(fromJSON('["all","ci"]'), inputs.rerun_group) && needs.evidence_reuse.outputs.reuse != 'true' }}
needs: [resolve_target, plugin_compatibility_readiness, evidence_reuse]
if: ${{ always() && github.run_attempt == 1 && needs.resolve_target.result == 'success' && contains(fromJSON('["success","skipped"]'), needs.plugin_compatibility_readiness.result) && contains(fromJSON('["success","skipped"]'), needs.evidence_reuse.result) && contains(fromJSON('["all","ci"]'), inputs.rerun_group) && needs.evidence_reuse.outputs.reuse != 'true' }}
runs-on: ubuntu-24.04
timeout-minutes: 15
outputs:
@ -1470,8 +1534,8 @@ jobs:
plugin_prerelease_independent:
name: Run plugin prerelease independent validation
needs: [resolve_target, evidence_reuse]
if: ${{ always() && github.run_attempt == 1 && needs.resolve_target.result == 'success' && contains(fromJSON('["success","skipped"]'), needs.evidence_reuse.result) && contains(fromJSON('["all","plugin-prerelease"]'), inputs.rerun_group) && needs.evidence_reuse.outputs.reuse != 'true' }}
needs: [resolve_target, plugin_compatibility_readiness, evidence_reuse]
if: ${{ always() && github.run_attempt == 1 && needs.resolve_target.result == 'success' && contains(fromJSON('["success","skipped"]'), needs.plugin_compatibility_readiness.result) && contains(fromJSON('["success","skipped"]'), needs.evidence_reuse.result) && contains(fromJSON('["all","plugin-prerelease"]'), inputs.rerun_group) && needs.evidence_reuse.outputs.reuse != 'true' }}
runs-on: ubuntu-24.04
timeout-minutes: 15
outputs:
@ -1522,8 +1586,8 @@ jobs:
release_checks_independent:
name: Run release checks independent validation
needs: [resolve_target, evidence_reuse]
if: ${{ always() && github.run_attempt == 1 && needs.resolve_target.result == 'success' && contains(fromJSON('["success","skipped"]'), needs.evidence_reuse.result) && contains(fromJSON('["all","install-smoke","live-e2e","qa-parity","qa-live"]'), inputs.rerun_group) && needs.evidence_reuse.outputs.reuse != 'true' }}
needs: [resolve_target, plugin_compatibility_readiness, evidence_reuse]
if: ${{ always() && github.run_attempt == 1 && needs.resolve_target.result == 'success' && contains(fromJSON('["success","skipped"]'), needs.plugin_compatibility_readiness.result) && contains(fromJSON('["success","skipped"]'), needs.evidence_reuse.result) && contains(fromJSON('["all","install-smoke","live-e2e","qa-parity","qa-live"]'), inputs.rerun_group) && needs.evidence_reuse.outputs.reuse != 'true' }}
runs-on: ${{ vars.OPENCLAW_CI_RUNNER_BACKEND == 'github' && 'ubuntu-24.04' || 'blacksmith-4vcpu-ubuntu-2404' }}
timeout-minutes: 15
outputs:
@ -1561,8 +1625,8 @@ jobs:
release_checks_candidate:
name: Run release checks candidate validation
needs: [resolve_target, evidence_reuse, candidate_acquisition]
if: ${{ always() && github.run_attempt == 1 && needs.resolve_target.result == 'success' && contains(fromJSON('["success","skipped"]'), needs.evidence_reuse.result) && (needs.resolve_target.outputs.release_candidate_artifact_required != 'true' || (needs.candidate_acquisition.result == 'success' && needs.candidate_acquisition.outputs.state == 'ready')) && (contains(fromJSON('["all","cross-os","package"]'), inputs.rerun_group) || (inputs.rerun_group == 'live-e2e' && needs.resolve_target.outputs.live_suite_filter == '')) && needs.evidence_reuse.outputs.reuse != 'true' }}
needs: [resolve_target, plugin_compatibility_readiness, evidence_reuse, candidate_acquisition]
if: ${{ always() && github.run_attempt == 1 && needs.resolve_target.result == 'success' && contains(fromJSON('["success","skipped"]'), needs.plugin_compatibility_readiness.result) && contains(fromJSON('["success","skipped"]'), needs.evidence_reuse.result) && (needs.resolve_target.outputs.release_candidate_artifact_required != 'true' || (needs.candidate_acquisition.result == 'success' && needs.candidate_acquisition.outputs.state == 'ready')) && (contains(fromJSON('["all","cross-os","package"]'), inputs.rerun_group) || (inputs.rerun_group == 'live-e2e' && needs.resolve_target.outputs.live_suite_filter == '')) && needs.evidence_reuse.outputs.reuse != 'true' }}
runs-on: ${{ vars.OPENCLAW_CI_RUNNER_BACKEND == 'github' && 'ubuntu-24.04' || 'blacksmith-4vcpu-ubuntu-2404' }}
timeout-minutes: 15
outputs:
@ -1600,8 +1664,8 @@ jobs:
npm_telegram:
name: Run package Telegram E2E
needs: [resolve_target, evidence_reuse]
if: ${{ always() && github.run_attempt == 1 && needs.resolve_target.result == 'success' && contains(fromJSON('["success","skipped"]'), needs.evidence_reuse.result) && inputs.telegram_waiver == '' && needs.resolve_target.outputs.coverage_policy != 'npm-beta-v1' && contains(fromJSON('["all","npm-telegram"]'), inputs.rerun_group) && (inputs.npm_telegram_package_spec != '' || inputs.release_package_spec != '') && needs.evidence_reuse.outputs.reuse != 'true' }}
needs: [resolve_target, plugin_compatibility_readiness, evidence_reuse]
if: ${{ always() && github.run_attempt == 1 && needs.resolve_target.result == 'success' && contains(fromJSON('["success","skipped"]'), needs.plugin_compatibility_readiness.result) && contains(fromJSON('["success","skipped"]'), needs.evidence_reuse.result) && inputs.telegram_waiver == '' && needs.resolve_target.outputs.coverage_policy != 'npm-beta-v1' && contains(fromJSON('["all","npm-telegram"]'), inputs.rerun_group) && (inputs.npm_telegram_package_spec != '' || inputs.release_package_spec != '') && needs.evidence_reuse.outputs.reuse != 'true' }}
continue-on-error: ${{ startsWith(github.ref, 'refs/heads/tideclaw/alpha/') }}
runs-on: ubuntu-24.04
timeout-minutes: 15
@ -1625,8 +1689,8 @@ jobs:
performance:
name: Run product performance evidence
needs: [resolve_target, evidence_reuse]
if: ${{ always() && github.run_attempt == 1 && needs.resolve_target.result == 'success' && contains(fromJSON('["success","skipped"]'), needs.evidence_reuse.result) && needs.resolve_target.outputs.coverage_policy != 'npm-beta-v1' && contains(fromJSON('["all","performance"]'), inputs.rerun_group) && needs.evidence_reuse.outputs.reuse != 'true' }}
needs: [resolve_target, plugin_compatibility_readiness, evidence_reuse]
if: ${{ always() && github.run_attempt == 1 && needs.resolve_target.result == 'success' && contains(fromJSON('["success","skipped"]'), needs.plugin_compatibility_readiness.result) && contains(fromJSON('["success","skipped"]'), needs.evidence_reuse.result) && needs.resolve_target.outputs.coverage_policy != 'npm-beta-v1' && contains(fromJSON('["all","performance"]'), inputs.rerun_group) && needs.evidence_reuse.outputs.reuse != 'true' }}
runs-on: ${{ vars.OPENCLAW_CI_RUNNER_BACKEND == 'github' && 'ubuntu-24.04' || 'blacksmith-4vcpu-ubuntu-2404' }}
timeout-minutes: 15
outputs:
@ -1647,8 +1711,8 @@ jobs:
prepare_npm_package:
name: Prepare release npm artifacts
needs: [resolve_target, evidence_reuse]
if: ${{ always() && needs.resolve_target.result == 'success' && contains(fromJSON('["success","skipped"]'), needs.evidence_reuse.result) && (inputs.rerun_group == 'all' || needs.resolve_target.outputs.candidate_required == 'true') }}
needs: [resolve_target, plugin_compatibility_readiness, evidence_reuse]
if: ${{ always() && needs.resolve_target.result == 'success' && contains(fromJSON('["success","skipped"]'), needs.plugin_compatibility_readiness.result) && contains(fromJSON('["success","skipped"]'), needs.evidence_reuse.result) && (inputs.rerun_group == 'all' || needs.resolve_target.outputs.candidate_required == 'true') }}
runs-on: ubuntu-24.04
timeout-minutes: 360
outputs:
@ -1736,8 +1800,8 @@ jobs:
prepare_docker_release:
name: Prepare release Docker artifacts
needs: [resolve_target, evidence_reuse]
if: ${{ always() && inputs.rerun_group == 'all' && needs.resolve_target.result == 'success' && contains(fromJSON('["success","skipped"]'), needs.evidence_reuse.result) && !contains(needs.resolve_target.outputs.target_version, '-alpha.') }}
needs: [resolve_target, plugin_compatibility_readiness, evidence_reuse]
if: ${{ always() && inputs.rerun_group == 'all' && needs.resolve_target.result == 'success' && contains(fromJSON('["success","skipped"]'), needs.plugin_compatibility_readiness.result) && contains(fromJSON('["success","skipped"]'), needs.evidence_reuse.result) && !contains(needs.resolve_target.outputs.target_version, '-alpha.') }}
runs-on: ubuntu-24.04
timeout-minutes: 360
outputs:

View file

@ -194,6 +194,24 @@ jobs:
OPENCLAW_LOCAL_CHECK: "0"
run: pnpm check --include-test-types --include-architecture
- name: Enforce plugin compatibility release readiness
env:
GH_TOKEN: ${{ github.token }}
run: |
set -euo pipefail
if jq -e '.scripts["plugins:boundary-report:ci"] | type == "string"' package.json >/dev/null; then
pnpm plugins:boundary-report:ci
exit 0
fi
gate_introduction=38ba27834dd3f98c19d5833e0598dfef3abb7587
target_sha="$(git rev-parse HEAD)"
relationship="$(gh api "repos/${GITHUB_REPOSITORY}/compare/${gate_introduction}...${target_sha}" --jq .status)"
if [[ "$relationship" != "behind" ]]; then
echo "Target is not proven to predate plugins:boundary-report:ci (relationship: $relationship)." >&2
exit 1
fi
echo '::warning::Frozen target predates the plugin compatibility release gate; skipping target-owned compatibility readiness.'
- name: Seal source-check evidence
id: source_evidence
env:

View file

@ -854,6 +854,7 @@ describe("retained publication admission", () => {
github: { run_attempt: 1 },
needs: {
resolve_target: { result: "success" },
plugin_compatibility_readiness: { result: "success" },
evidence_reuse: { result: "failure" },
},
}),

View file

@ -1291,6 +1291,7 @@ globalThis.Date = class extends OriginalDate {
candidate_required: steps.candidate_request!.outputs.required,
},
},
plugin_compatibility_readiness: { result: "success" },
evidence_reuse: { result: "skipped", outputs: { reuse: "false" } },
},
});
@ -1925,6 +1926,7 @@ describe("FRV publication source admission", () => {
id === "docker_runtime_assets_preflight" ? "2026.9.9-alpha.1" : "2026.9.9",
},
},
plugin_compatibility_readiness: { result: "success" },
evidence_reuse: { result: "skipped", outputs: { reuse: "false" } },
},
}),
@ -2284,6 +2286,7 @@ describe("FRV publication source admission", () => {
id === "docker_runtime_assets_preflight" ? "2026.9.9-alpha.1" : "2026.9.9",
},
},
plugin_compatibility_readiness: { result: "success" },
evidence_reuse: { result: "skipped", outputs: { reuse: "false" } },
},
}),

View file

@ -1,20 +1,13 @@
import { spawnSync } from "node:child_process";
import {
chmodSync,
existsSync,
mkdirSync,
mkdtempSync,
readFileSync,
rmSync,
writeFileSync,
} from "node:fs";
import { tmpdir } from "node:os";
import { chmodSync, existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
import { join } from "node:path";
import { describe, expect, it } from "vitest";
import { afterEach, describe, expect, it } from "vitest";
import { parse } from "yaml";
import { useAutoCleanupTempDirTracker } from "../helpers/temp-dir.js";
const workflowPath = ".github/workflows/openclaw-npm-release.yml";
const preflightWorkflowPath = ".github/workflows/openclaw-npm-preflight.yml";
const tempDirs = useAutoCleanupTempDirTracker(afterEach);
type Step = {
env?: Record<string, string>;
@ -70,7 +63,7 @@ function step(job: Job | undefined, name: string): Step {
}
function runControlUiArtifactStep(options: { artifactPresent: boolean }) {
const root = mkdtempSync(join(tmpdir(), "openclaw-npm-preflight-ui-"));
const root = tempDirs.make("openclaw-npm-preflight-ui-");
const binDir = join(root, "bin");
const artifactPath = join(root, "dist", "control-ui", "index.html");
const invocationPath = join(root, "pnpm-invocation.txt");
@ -122,10 +115,54 @@ printf '<!doctype html>\\n' > "${artifactPath}"
: null;
const artifactExists = existsSync(artifactPath);
const targetHasTsxLoader = existsSync(join(root, "scripts", "tsx.mjs"));
rmSync(root, { force: true, recursive: true });
return { artifactExists, invocation, result, targetHasTsxLoader };
}
function runPluginCompatibilityGate(options: { hasScript: boolean; relationship: string }) {
const root = tempDirs.make("openclaw-npm-plugin-compat-");
const binDir = join(root, "bin");
const invocationPath = join(root, "pnpm-invocation.txt");
const apiPath = join(root, "gh-invocation.txt");
mkdirSync(binDir);
writeFileSync(
join(root, "package.json"),
JSON.stringify({
scripts: options.hasScript ? { "plugins:boundary-report:ci": "node check.mjs" } : {},
}),
);
for (const [name, script] of [
["git", `#!/usr/bin/env bash\nprintf '%040d\\n' 0\n`],
[
"gh",
`#!/usr/bin/env bash\nprintf '%s\\n' "$*" > ${JSON.stringify(apiPath)}\nprintf '%s\\n' "$RELATIONSHIP"\n`,
],
["pnpm", `#!/usr/bin/env bash\nprintf '%s\\n' "$*" > ${JSON.stringify(invocationPath)}\n`],
] as const) {
const path = join(binDir, name);
writeFileSync(path, script);
chmodSync(path, 0o755);
}
const gate = step(
workflow(preflightWorkflowPath).jobs?.check_openclaw_npm,
"Enforce plugin compatibility release readiness",
);
const result = spawnSync("bash", ["--noprofile", "--norc", "-c", gate.run ?? ""], {
cwd: root,
encoding: "utf8",
env: {
...process.env,
GITHUB_REPOSITORY: "openclaw/openclaw",
PATH: `${binDir}:${process.env.PATH ?? ""}`,
RELATIONSHIP: options.relationship,
},
});
const invocation = existsSync(invocationPath)
? readFileSync(invocationPath, "utf8").trim()
: null;
const apiInvocation = existsSync(apiPath) ? readFileSync(apiPath, "utf8").trim() : null;
return { apiInvocation, invocation, result };
}
describe("minimal npm extended-stable workflow", () => {
it("bounds every git fetch operation", () => {
const source = [workflowPath, preflightWorkflowPath]
@ -154,6 +191,10 @@ describe("minimal npm extended-stable workflow", () => {
parsed.jobs?.check_openclaw_npm,
"Check source, test types, and architecture",
);
const pluginCompatibility = step(
parsed.jobs?.check_openclaw_npm,
"Enforce plugin compatibility release readiness",
);
const trustedCheckout = step(
parsed.jobs?.check_openclaw_npm,
"Checkout trusted package source preflight",
@ -185,12 +226,18 @@ describe("minimal npm extended-stable workflow", () => {
);
}
expect(sourceCheck.run).toBe("pnpm check --include-test-types --include-architecture");
expect(pluginCompatibility.run).toContain('.scripts["plugins:boundary-report:ci"]');
expect(pluginCompatibility.run).toContain("38ba27834dd3f98c19d5833e0598dfef3abb7587");
expect(pluginCompatibility.run).toContain("Target is not proven to predate");
expect(pluginCompatibility.run).toContain("Frozen target predates");
expect(pluginCompatibility.run).toContain("pnpm plugins:boundary-report:ci");
expect(metadata).toContain("--unshallow origin");
expect(metadata).toContain('"+refs/tags/v*:refs/tags/v*"');
const sourceSteps = parsed.jobs?.check_openclaw_npm?.steps ?? [];
const prepareSteps = parsed.jobs?.prepare_openclaw_npm?.steps ?? [];
expect(sourceSteps.indexOf(trustedCheckout)).toBeLessThan(sourceSteps.indexOf(sourceAncestry));
expect(sourceSteps.indexOf(sourceAncestry)).toBeLessThan(sourceSteps.indexOf(sourceCheck));
expect(sourceSteps.indexOf(sourceCheck)).toBeLessThan(sourceSteps.indexOf(pluginCompatibility));
expect(prepareSteps.indexOf(tideclawAncestry)).toBeGreaterThan(
prepareSteps.findIndex(
(candidate) => candidate.name === "Checkout trusted package source preflight",
@ -203,6 +250,52 @@ describe("minimal npm extended-stable workflow", () => {
);
});
it.each([
{
label: "current target with the gate",
hasScript: true,
relationship: "ahead",
status: 0,
invocation: "plugins:boundary-report:ci",
api: false,
},
{
label: "historical target before the gate",
hasScript: false,
relationship: "behind",
status: 0,
invocation: null,
api: true,
},
{
label: "current target missing the gate",
hasScript: false,
relationship: "ahead",
status: 1,
invocation: null,
api: true,
},
{
label: "diverged target missing the gate",
hasScript: false,
relationship: "diverged",
status: 1,
invocation: null,
api: true,
},
])("enforces plugin compatibility admission for $label", (testCase) => {
const run = runPluginCompatibilityGate(testCase);
expect(run.result.status, run.result.stderr).toBe(testCase.status);
expect(run.invocation).toBe(testCase.invocation);
expect(run.apiInvocation !== null).toBe(testCase.api);
if (testCase.relationship === "behind") {
expect(run.result.stdout).toContain("Frozen target predates");
}
if (testCase.status === 1) {
expect(run.result.stderr).toContain("not proven to predate");
}
});
it("adds extended-stable without adding policy or verifier contracts", () => {
const raw = readFileSync(workflowPath, "utf8");
const parsed = workflow();

View file

@ -9245,7 +9245,11 @@ describe("package artifact reuse", () => {
const qualify = workflowJob(FULL_RELEASE_VALIDATION_WORKFLOW, "qualify_npm_package");
const candidate = workflowJob(FULL_RELEASE_VALIDATION_WORKFLOW, "candidate_acquisition");
for (const job of [prepare, docker]) {
expect(jobNeeds(job)).toEqual(["resolve_target", "evidence_reuse"]);
expect(jobNeeds(job)).toEqual([
"resolve_target",
"plugin_compatibility_readiness",
"evidence_reuse",
]);
}
expect(jobNeeds(qualify)).toEqual(["resolve_target", "prepare_npm_package"]);
expect(qualify.if).toBe(
@ -12760,6 +12764,18 @@ printf '%s\\n' "$DEEPSEEK_API_KEY" "$DEEPINFRA_API_KEY"`,
resolveTargetJob,
"Checkout target package manifest",
);
const detectPluginCompatibility = workflowStep(
resolveTargetJob,
"Detect target plugin compatibility gate",
);
const pluginCompatibilityJob = workflowJob(
FULL_RELEASE_VALIDATION_WORKFLOW,
"plugin_compatibility_readiness",
);
const enforcePluginCompatibility = workflowStep(
pluginCompatibilityJob,
"Enforce target compatibility readiness",
);
const toolingIdentity = workflowStep(resolveTargetJob, "Resolve trusted workflow identity");
const releaseInputValidation = workflowStep(resolveTargetJob, "Validate release inputs");
const evidenceReuseStep = workflowStep(evidenceReuseJob, "Find reusable validation evidence");
@ -12797,6 +12813,68 @@ printf '%s\\n' "$DEEPSEEK_API_KEY" "$DEEPINFRA_API_KEY"`,
expect(resolveTargetSteps.indexOf(targetManifestCheckout)).toBeLessThan(
resolveTargetSteps.indexOf(releaseInputValidation),
);
expect(resolveTargetSteps.indexOf(targetManifestCheckout)).toBeLessThan(
resolveTargetSteps.indexOf(detectPluginCompatibility),
);
expect(detectPluginCompatibility.run).toContain('.scripts["plugins:boundary-report:ci"]');
expect(detectPluginCompatibility.run).toContain("38ba27834dd3f98c19d5833e0598dfef3abb7587");
expect(detectPluginCompatibility.run).toContain("Current target is missing");
expect(detectPluginCompatibility.run).toContain("required=false");
expect(resolveTargetJob.outputs?.plugin_compatibility_required).toBe(
"${{ steps.plugin_compatibility.outputs.required }}",
);
expect(pluginCompatibilityJob.needs).toEqual(["resolve_target"]);
expect(pluginCompatibilityJob.if).toBe(
"needs.resolve_target.outputs.plugin_compatibility_required == 'true'",
);
expect(enforcePluginCompatibility.run).toBe("pnpm plugins:boundary-report:ci");
for (const jobName of [
"docker_runtime_assets_preflight",
"normal_ci",
"plugin_prerelease_independent",
"release_checks_independent",
"release_checks_candidate",
"npm_telegram",
"performance",
"prepare_npm_package",
"prepare_docker_release",
]) {
const job = workflowJob(FULL_RELEASE_VALIDATION_WORKFLOW, jobName);
expect(jobNeeds(job), jobName).toContain("plugin_compatibility_readiness");
expect(String(job.if), jobName).toContain("needs.plugin_compatibility_readiness.result");
}
const candidateCondition = String(releaseChecksJob.if).replace(
/^\$\{\{\s*([\s\S]*?)\s*\}\}$/u,
"$1",
);
for (const [compatibilityResult, admitted] of [
["success", true],
["skipped", true],
["failure", false],
["cancelled", false],
] as const) {
const result = runInNewContext(candidateCondition, {
github: { run_attempt: 1 },
inputs: { release_package_spec: "openclaw@next", rerun_group: "cross-os" },
needs: {
resolve_target: {
result: "success",
outputs: { live_suite_filter: "", release_candidate_artifact_required: "false" },
},
plugin_compatibility_readiness: { result: compatibilityResult },
evidence_reuse: { result: "success", outputs: { reuse: "false" } },
candidate_acquisition: { result: "skipped", outputs: {} },
},
always: () => true,
contains: (values: string | string[], value: string) => values.includes(value),
fromJSON: JSON.parse,
});
expect(Boolean(result), compatibilityResult).toBe(admitted);
}
expect(jobNeeds(evidenceReuseJob)).toContain("plugin_compatibility_readiness");
expect(evidenceReuseJob.if).toContain("always()");
expect(evidenceReuseJob.if).toContain("needs.resolve_target.result == 'success'");
expect(evidenceReuseJob.if).toContain("needs.plugin_compatibility_readiness.result");
expect(resolveTargetJob.outputs?.trusted_workflow_json).toBe(
"${{ steps.tooling_identity.outputs.json }}",
);
@ -12821,7 +12899,11 @@ printf '%s\\n' "$DEEPSEEK_API_KEY" "$DEEPINFRA_API_KEY"`,
"target_context_ref must be a canonical OpenClaw release branch or tag.",
]);
expect(npmTelegramJob.name).toBe("Run package Telegram E2E");
expect(npmTelegramJob.needs).toEqual(["resolve_target", "evidence_reuse"]);
expect(npmTelegramJob.needs).toEqual([
"resolve_target",
"plugin_compatibility_readiness",
"evidence_reuse",
]);
expect(npmTelegramJob["timeout-minutes"]).toBe(15);
expect(performanceJob["timeout-minutes"]).toBe(15);
expect(npmTelegramJob.if).toContain(
@ -15352,6 +15434,7 @@ wait_for_run plugin-clawhub-new.yml 123 "${expectedSha}" || status=$?
const candidateBinding = workflowJob(FULL_RELEASE_CANDIDATE_WORKFLOW, "resolve_candidate");
expect(jobNeeds(workflowJob(FULL_RELEASE_VALIDATION_WORKFLOW, "evidence_reuse"))).toEqual([
"resolve_target",
"plugin_compatibility_readiness",
]);
expect(jobNeeds(candidateAcquisition)).toEqual([
"resolve_target",
@ -15363,6 +15446,7 @@ wait_for_run plugin-clawhub-new.yml 123 "${expectedSha}" || status=$?
expect(jobNeeds(candidateBinding)).toEqual(["discover", "prepare"]);
expect(jobNeeds(releaseChecksParent)).toEqual([
"resolve_target",
"plugin_compatibility_readiness",
"evidence_reuse",
"candidate_acquisition",
]);
@ -15371,6 +15455,10 @@ wait_for_run plugin-clawhub-new.yml 123 "${expectedSha}" || status=$?
);
const fullParentPath = [
timeoutForProfile(fullRelease.jobs?.resolve_target?.["timeout-minutes"], "full"),
timeoutForProfile(
fullRelease.jobs?.plugin_compatibility_readiness?.["timeout-minutes"],
"full",
),
timeoutForProfile(fullRelease.jobs?.evidence_reuse?.["timeout-minutes"], "full"),
timeoutForProfile(fullReleaseCandidate.jobs?.discover?.["timeout-minutes"], "full"),
timeoutForProfile(liveE2e.jobs?.validate_selected_ref?.["timeout-minutes"], "full"),
@ -15382,9 +15470,9 @@ wait_for_run plugin-clawhub-new.yml 123 "${expectedSha}" || status=$?
timeoutForProfile(candidateBinding["timeout-minutes"], "full"),
timeoutForProfile(releaseChecksParent["timeout-minutes"], "full"),
];
expect(fullParentPath).toEqual([10, 10, 10, 30, 90, 15, 5, 15]);
expect(fullParentPath).toEqual([10, 10, 10, 10, 30, 90, 15, 5, 15]);
const fullParentTimeoutFloor = fullParentPath.reduce((total, timeout) => total + timeout, 0);
expect(fullParentTimeoutFloor).toBe(185);
expect(fullParentTimeoutFloor).toBe(195);
expect(FULL_RELEASE_WAIT_TIMEOUT_MINUTES).toBe(diagnosticDrainTimeout);
});

View file

@ -1204,7 +1204,11 @@ describe("scripts/lib/plugin-prerelease-test-plan.mts", () => {
expect(fullReleaseWorkflow.jobs[jobName]["runs-on"]).toBe("ubuntu-24.04");
}
expect(fullReleaseWorkflow.jobs.normal_ci["timeout-minutes"]).toBe(15);
expect(fullReleaseWorkflow.jobs.normal_ci.needs).toEqual(["resolve_target", "evidence_reuse"]);
expect(fullReleaseWorkflow.jobs.normal_ci.needs).toEqual([
"resolve_target",
"plugin_compatibility_readiness",
"evidence_reuse",
]);
expect(fullReleaseWorkflow.jobs.normal_ci.if).toContain(
"needs.resolve_target.result == 'success'",
);
@ -1212,7 +1216,7 @@ describe("scripts/lib/plugin-prerelease-test-plan.mts", () => {
"needs.evidence_reuse.outputs.reuse != 'true'",
);
expect(fullReleaseWorkflow.jobs.docker_runtime_assets_preflight.if).toBe(
"${{ always() && github.run_attempt == 1 && needs.resolve_target.result == 'success' && contains(fromJSON('[\"success\",\"skipped\"]'), needs.evidence_reuse.result) && inputs.rerun_group == 'all' && contains(needs.resolve_target.outputs.target_version, '-alpha.') && needs.evidence_reuse.outputs.reuse != 'true' }}",
"${{ always() && github.run_attempt == 1 && needs.resolve_target.result == 'success' && contains(fromJSON('[\"success\",\"skipped\"]'), needs.plugin_compatibility_readiness.result) && contains(fromJSON('[\"success\",\"skipped\"]'), needs.evidence_reuse.result) && inputs.rerun_group == 'all' && contains(needs.resolve_target.outputs.target_version, '-alpha.') && needs.evidence_reuse.outputs.reuse != 'true' }}",
);
expect(fullReleaseWorkflow.jobs.docker_runtime_assets_preflight["timeout-minutes"]).toBe(20);
const dockerPreflightStep = fullReleaseWorkflow.jobs.docker_runtime_assets_preflight.steps.find(