diff --git a/.github/workflows/full-release-validation.yml b/.github/workflows/full-release-validation.yml index 282f9075703c..e7a0806ae922 100644 --- a/.github/workflows/full-release-validation.yml +++ b/.github/workflows/full-release-validation.yml @@ -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: diff --git a/.github/workflows/openclaw-npm-preflight.yml b/.github/workflows/openclaw-npm-preflight.yml index 49e574d34e3c..ac3bd30c73fa 100644 --- a/.github/workflows/openclaw-npm-preflight.yml +++ b/.github/workflows/openclaw-npm-preflight.yml @@ -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: diff --git a/test/scripts/full-release-artifact-contract.test.ts b/test/scripts/full-release-artifact-contract.test.ts index 7b252800aed6..35e4a2f3a68c 100644 --- a/test/scripts/full-release-artifact-contract.test.ts +++ b/test/scripts/full-release-artifact-contract.test.ts @@ -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" }, }, }), diff --git a/test/scripts/full-release-publication-admission.test.ts b/test/scripts/full-release-publication-admission.test.ts index 741264c5861f..47a42b510bcf 100644 --- a/test/scripts/full-release-publication-admission.test.ts +++ b/test/scripts/full-release-publication-admission.test.ts @@ -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" } }, }, }), diff --git a/test/scripts/openclaw-npm-extended-stable-workflow.test.ts b/test/scripts/openclaw-npm-extended-stable-workflow.test.ts index 0c71e536ff65..b955c066cd9c 100644 --- a/test/scripts/openclaw-npm-extended-stable-workflow.test.ts +++ b/test/scripts/openclaw-npm-extended-stable-workflow.test.ts @@ -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; @@ -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 '\\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(); diff --git a/test/scripts/package-acceptance-workflow.test.ts b/test/scripts/package-acceptance-workflow.test.ts index 991579a249c1..8d6945835e53 100644 --- a/test/scripts/package-acceptance-workflow.test.ts +++ b/test/scripts/package-acceptance-workflow.test.ts @@ -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); }); diff --git a/test/scripts/plugin-prerelease-test-plan.test.ts b/test/scripts/plugin-prerelease-test-plan.test.ts index 1472603d13e2..6b7db9761174 100644 --- a/test/scripts/plugin-prerelease-test-plan.test.ts +++ b/test/scripts/plugin-prerelease-test-plan.test.ts @@ -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(