From 7ae5996bb3c5c8d072b13288e974f7c52a719fe2 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sat, 11 Jul 2026 05:44:49 -0700 Subject: [PATCH] fix(ci): validate older release targets (#103975) * fix(ci): support older release targets * fix(ci): guard optional channel contracts * fix(ci): scope legacy release compatibility --- .github/workflows/ci.yml | 176 +++++++++++--- .github/workflows/full-release-validation.yml | 6 +- test/scripts/ci-workflow-guards.test.ts | 218 +++++++++++++++++- .../plugin-prerelease-test-plan.test.ts | 13 +- 4 files changed, 369 insertions(+), 44 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index bd5be3c5f9c2..8f84dab5d04d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -23,6 +23,11 @@ on: required: false default: "" type: string + historical_target_tag: + description: Semver release tag authorizing compatibility fallbacks for its exact commit + required: false + default: "" + type: string push: branches: [main] paths-ignore: @@ -88,9 +93,12 @@ jobs: run_build_artifacts: ${{ steps.manifest.outputs.run_build_artifacts }} run_checks_fast_core: ${{ steps.manifest.outputs.run_checks_fast_core }} run_checks_fast: ${{ steps.manifest.outputs.run_checks_fast }} + historical_target: ${{ steps.manifest.outputs.historical_target }} + run_qa_smoke_ci: ${{ steps.manifest.outputs.run_qa_smoke_ci }} checks_fast_core_matrix: ${{ steps.manifest.outputs.checks_fast_core_matrix }} run_plugin_contracts_shards: ${{ steps.manifest.outputs.run_plugin_contracts_shards }} plugin_contracts_matrix: ${{ steps.manifest.outputs.plugin_contracts_matrix }} + run_channel_contracts_shards: ${{ steps.manifest.outputs.run_channel_contracts_shards }} channel_contracts_matrix: ${{ steps.manifest.outputs.channel_contracts_matrix }} run_checks: ${{ steps.manifest.outputs.run_checks }} run_checks_node_core_nondist: ${{ steps.manifest.outputs.run_checks_node_core_nondist }} @@ -109,6 +117,7 @@ jobs: run_macos_swift: ${{ steps.manifest.outputs.run_macos_swift }} run_ios_build: ${{ steps.manifest.outputs.run_ios_build }} run_android_job: ${{ steps.manifest.outputs.run_android_job }} + run_protocol_event_coverage: ${{ steps.manifest.outputs.run_protocol_event_coverage }} android_matrix: ${{ steps.manifest.outputs.android_matrix }} steps: - name: Validate release-gate dispatch @@ -205,6 +214,30 @@ jobs: id: checkout_ref run: echo "sha=$(git rev-parse HEAD)" >> "$GITHUB_OUTPUT" + - name: Validate historical release target + id: historical_target + if: inputs.historical_target_tag != '' + env: + EXPECTED_SHA: ${{ steps.checkout_ref.outputs.sha }} + HISTORICAL_TARGET_TAG: ${{ inputs.historical_target_tag }} + run: | + set -euo pipefail + if [[ ! "$HISTORICAL_TARGET_TAG" =~ ^v[0-9]{4}\.[0-9]+\.[0-9]+(-(alpha|beta)\.[0-9]+)?$ ]]; then + echo "historical_target_tag must be a canonical OpenClaw release tag." >&2 + exit 1 + fi + tag_ref="refs/tags/${HISTORICAL_TARGET_TAG}" + remote="$(git remote get-url origin)" + tag_sha="$(git ls-remote --tags "$remote" "${tag_ref}^{}" | awk 'NR == 1 { print $1 }')" + if [[ -z "$tag_sha" ]]; then + tag_sha="$(git ls-remote --tags "$remote" "$tag_ref" | awk 'NR == 1 { print $1 }')" + fi + if [[ "$tag_sha" != "$EXPECTED_SHA" ]]; then + echo "Historical release tag ${HISTORICAL_TARGET_TAG} does not resolve to ${EXPECTED_SHA}." >&2 + exit 1 + fi + echo "eligible=true" >> "$GITHUB_OUTPUT" + - name: Ensure preflight base commit if: github.event_name != 'workflow_dispatch' uses: ./.github/actions/ensure-base-commit @@ -250,17 +283,50 @@ jobs: OPENCLAW_CI_RUN_UI_TESTS: ${{ github.event_name == 'workflow_dispatch' && 'true' || steps.changed_scope.outputs.run_ui_tests || 'false' }} OPENCLAW_CI_RUN_NATIVE_I18N: ${{ github.event_name == 'workflow_dispatch' && 'true' || steps.changed_scope.outputs.run_native_i18n || 'false' }} OPENCLAW_CI_CHECKOUT_REVISION: ${{ steps.checkout_ref.outputs.sha }} + OPENCLAW_CI_HISTORICAL_TARGET: ${{ steps.historical_target.outputs.eligible || 'false' }} + OPENCLAW_CI_WORKFLOW_REVISION: ${{ github.sha }} OPENCLAW_CI_REPOSITORY: ${{ github.repository }} OPENCLAW_CI_EVENT_NAME: ${{ github.event_name }} run: | node --input-type=module <<'EOF' - import { appendFileSync } from "node:fs"; - import { - createNodeTestShardBundles, - } from "./scripts/lib/ci-node-test-plan.mjs"; - import { - createChannelContractTestShards, - } from "./scripts/lib/channel-contract-test-plan.mjs"; + import { appendFileSync, existsSync, readFileSync } from "node:fs"; + + const eventName = process.env.OPENCLAW_CI_EVENT_NAME ?? ""; + const checkoutRevision = process.env.OPENCLAW_CI_CHECKOUT_REVISION ?? ""; + const workflowRevision = process.env.OPENCLAW_CI_WORKFLOW_REVISION ?? ""; + const historicalTargetApproved = process.env.OPENCLAW_CI_HISTORICAL_TARGET === "true"; + const historicalTarget = + eventName === "workflow_dispatch" && + historicalTargetApproved && + checkoutRevision !== workflowRevision; + + const nodeTestPlan = await import("./scripts/lib/ci-node-test-plan.mjs"); + const createNodeTestPlan = + typeof nodeTestPlan.createNodeTestShardBundles === "function" + ? nodeTestPlan.createNodeTestShardBundles + : historicalTarget + ? nodeTestPlan.createNodeTestShards + : undefined; + if (typeof createNodeTestPlan !== "function") { + throw new Error("CI target does not export a supported Node test shard planner"); + } + + const importTargetPlan = async (path) => { + if (existsSync(path)) { + return import(path); + } + if (!historicalTarget) { + throw new Error(`Current CI target does not provide ${path}`); + } + return {}; + }; + const channelContractPlan = await importTargetPlan( + "./scripts/lib/channel-contract-test-plan.mjs", + ); + const createChannelContractTestShards = + typeof channelContractPlan.createChannelContractTestShards === "function" + ? channelContractPlan.createChannelContractTestShards + : () => []; const parseBoolean = (value, fallback = false) => { if (value === undefined) return fallback; @@ -270,29 +336,27 @@ jobs: return fallback; }; - const { createPluginContractTestShards } = await import( - "./scripts/lib/plugin-contract-test-plan.mjs" - ).catch((error) => { - if (error?.code !== "ERR_MODULE_NOT_FOUND") { - throw error; - } - return { - createPluginContractTestShards: () => [ - { - checkName: "checks-fast-contracts-plugins-legacy", - includePatterns: ["src/plugins/contracts/**/*.test.ts"], - runtime: "node", - task: "contracts-plugins", - }, - ], - }; - }); + const pluginContractPlan = await importTargetPlan( + "./scripts/lib/plugin-contract-test-plan.mjs", + ); + const createPluginContractTestShards = + typeof pluginContractPlan.createPluginContractTestShards === "function" + ? pluginContractPlan.createPluginContractTestShards + : () => [ + { + checkName: "checks-fast-contracts-plugins-legacy", + includePatterns: ["src/plugins/contracts/**/*.test.ts"], + runtime: "node", + task: "contracts-plugins", + }, + ]; const createMatrix = (include) => ({ include }); const outputPath = process.env.GITHUB_OUTPUT; + const packageScripts = JSON.parse(readFileSync("package.json", "utf8")).scripts ?? {}; + const hasPackageScript = (name) => typeof packageScripts[name] === "string"; const isCanonicalRepository = process.env.OPENCLAW_CI_REPOSITORY === "openclaw/openclaw"; const docsOnly = parseBoolean(process.env.OPENCLAW_CI_DOCS_ONLY); const docsChanged = parseBoolean(process.env.OPENCLAW_CI_DOCS_CHANGED); - const eventName = process.env.OPENCLAW_CI_EVENT_NAME ?? ""; const runNode = parseBoolean(process.env.OPENCLAW_CI_RUN_NODE) && !docsOnly; const runNodeFastOnly = runNode && parseBoolean(process.env.OPENCLAW_CI_RUN_NODE_FAST_ONLY); @@ -304,8 +368,16 @@ jobs: const runPluginContractShards = runNodeFull || runNodeFastPluginContracts; const runMacos = parseBoolean(process.env.OPENCLAW_CI_RUN_MACOS) && !docsOnly && isCanonicalRepository; + const supportsCurrentIosCi = + hasPackageScript("ios:build") && + existsSync("scripts/install-swift-tools.sh") && + existsSync("scripts/lint-swift.sh") && + existsSync("scripts/format-swift.sh"); const runIosBuild = - parseBoolean(process.env.OPENCLAW_CI_RUN_IOS_BUILD) && !docsOnly && isCanonicalRepository; + parseBoolean(process.env.OPENCLAW_CI_RUN_IOS_BUILD) && + !docsOnly && + isCanonicalRepository && + (!historicalTarget || supportsCurrentIosCi); const runAndroid = parseBoolean(process.env.OPENCLAW_CI_RUN_ANDROID) && !docsOnly && isCanonicalRepository; const runWindows = @@ -317,8 +389,14 @@ jobs: const runControlUiI18n = parseBoolean(process.env.OPENCLAW_CI_RUN_CONTROL_UI_I18N) && !docsOnly; const runUiTests = parseBoolean(process.env.OPENCLAW_CI_RUN_UI_TESTS) && !docsOnly; + const supportsNativeI18n = + hasPackageScript("native:i18n:check") && + hasPackageScript("android:i18n:check") && + hasPackageScript("apple:i18n:check"); const runNativeI18n = - parseBoolean(process.env.OPENCLAW_CI_RUN_NATIVE_I18N) && !docsOnly; + parseBoolean(process.env.OPENCLAW_CI_RUN_NATIVE_I18N) && + !docsOnly && + (!historicalTarget || supportsNativeI18n); const checksFastCoreTasks = []; if (runNodeFull) { checksFastCoreTasks.push( @@ -336,8 +414,11 @@ jobs: } const compactPullRequest = isCanonicalRepository && eventName === "pull_request"; + const runQaSmokeCi = + runNodeFull && + (!historicalTarget || existsSync("extensions/qa-lab/src/ci-smoke-plan.ts")); const nodeTestShards = runNodeFull - ? createNodeTestShardBundles({ + ? createNodeTestPlan({ includeReleaseOnlyPluginShards: false, compact: compactPullRequest, }).map((shard) => ({ @@ -359,6 +440,11 @@ jobs: : []; const nodeTestNonDistShards = nodeTestShards.filter((shard) => !shard.requires_dist); const nodeTestDistShards = nodeTestShards.filter((shard) => shard.requires_dist); + const channelContractShards = runNodeFull ? createChannelContractTestShards() : []; + const protocolCoverageRequested = runNode || runIosBuild || runAndroid; + const runProtocolEventCoverage = + protocolCoverageRequested && + (!historicalTarget || existsSync("scripts/check-protocol-event-coverage.mjs")); const manifest = { docs_only: docsOnly, @@ -371,14 +457,15 @@ jobs: run_build_artifacts: runNodeFull, run_checks_fast_core: checksFastCoreTasks.length > 0, run_checks_fast: runNodeFull, + historical_target: historicalTarget, + run_qa_smoke_ci: runQaSmokeCi, checks_fast_core_matrix: createMatrix(checksFastCoreTasks), run_plugin_contracts_shards: runPluginContractShards, plugin_contracts_matrix: createMatrix( runPluginContractShards ? createPluginContractTestShards() : [], ), - channel_contracts_matrix: createMatrix( - runNodeFull ? createChannelContractTestShards() : [], - ), + run_channel_contracts_shards: channelContractShards.length > 0, + channel_contracts_matrix: createMatrix(channelContractShards), run_checks: runNodeFull, run_checks_node_core_nondist: nodeTestNonDistShards.length > 0, checks_node_core_nondist_matrix: createMatrix(nodeTestNonDistShards), @@ -410,6 +497,7 @@ jobs: run_macos_swift: runMacos, run_ios_build: runIosBuild, run_android_job: runAndroid, + run_protocol_event_coverage: runProtocolEventCoverage, android_matrix: createMatrix( runAndroid ? [ @@ -432,7 +520,7 @@ jobs: EOF - name: Check mobile protocol event coverage - if: ${{ steps.manifest.outputs.run_node == 'true' || steps.manifest.outputs.run_ios_build == 'true' || steps.manifest.outputs.run_android_job == 'true' }} + if: steps.manifest.outputs.run_protocol_event_coverage == 'true' run: node scripts/check-protocol-event-coverage.mjs # Run dependency-free security checks in parallel with scope detection so the @@ -965,7 +1053,7 @@ jobs: contents: read name: QA Smoke CI (${{ matrix.name }}) needs: [preflight] - if: needs.preflight.outputs.run_checks_fast == 'true' + if: needs.preflight.outputs.run_qa_smoke_ci == 'true' runs-on: ${{ github.event_name == 'workflow_dispatch' && 'ubuntu-24.04' || (github.repository == 'openclaw/openclaw' && 'blacksmith-16vcpu-ubuntu-2404' || 'ubuntu-24.04') }} timeout-minutes: 60 strategy: @@ -1118,7 +1206,7 @@ jobs: contents: read name: ${{ matrix.checkName }} needs: [preflight] - if: needs.preflight.outputs.run_checks_fast == 'true' + if: needs.preflight.outputs.run_channel_contracts_shards == 'true' runs-on: ${{ github.event_name == 'workflow_dispatch' && 'ubuntu-24.04' || (github.repository == 'openclaw/openclaw' && 'blacksmith-4vcpu-ubuntu-2404' || 'ubuntu-24.04') }} timeout-minutes: 60 strategy: @@ -1331,6 +1419,7 @@ jobs: - name: Run check shard env: + HISTORICAL_TARGET: ${{ needs.preflight.outputs.historical_target }} OPENCLAW_LOCAL_CHECK: "0" TASK: ${{ matrix.task }} PR_BASE_SHA: ${{ github.event_name == 'pull_request' && github.event.pull_request.base.sha || '' }} @@ -1377,7 +1466,12 @@ jobs: ;; test-types) pnpm check:test-types - pnpm tsgo:scripts + if pnpm run --silent 2>/dev/null | grep -q '^ tsgo:scripts$'; then + pnpm tsgo:scripts + elif [[ "$HISTORICAL_TARGET" != "true" ]]; then + echo "Current CI targets must provide the tsgo:scripts package script." >&2 + exit 1 + fi ;; *) echo "Unsupported check task: $TASK" >&2 @@ -1878,6 +1972,8 @@ jobs: if: needs.preflight.outputs.run_macos_swift == 'true' runs-on: ${{ github.event_name == 'workflow_dispatch' && 'macos-26' || (github.repository == 'openclaw/openclaw' && 'blacksmith-12vcpu-macos-26' || 'macos-26') }} timeout-minutes: 20 + env: + HISTORICAL_TARGET: ${{ needs.preflight.outputs.historical_target }} steps: - *platform_checkout_step @@ -1891,9 +1987,12 @@ jobs: echo "$swift_tools_dir" >> "$GITHUB_PATH" "$swift_tools_dir/swiftformat" --version "$swift_tools_dir/swiftlint" version - else + elif [[ "$HISTORICAL_TARGET" == "true" ]]; then # Frozen release targets before the pinned installer used Homebrew directly. brew install xcodegen swiftlint swiftformat + else + echo "Current CI targets must provide scripts/install-swift-tools.sh." >&2 + exit 1 fi - name: Detect Swift toolchain cache key @@ -1960,10 +2059,13 @@ jobs: if [[ -x ./scripts/lint-swift.sh && -x ./scripts/format-swift.sh ]]; then ./scripts/lint-swift.sh macos ./scripts/format-swift.sh macos - else + elif [[ "$HISTORICAL_TARGET" == "true" ]]; then # Frozen release targets before the shared wrappers used these commands directly. swiftlint lint --config config/swiftlint.yml swiftformat --lint apps/macos/Sources --config config/swiftformat --exclude '**/OpenClawProtocol,**/HostEnvSecurityPolicy.generated.swift' + else + echo "Current CI targets must provide the Swift lint and format wrappers." >&2 + exit 1 fi - name: Swift build (release) diff --git a/.github/workflows/full-release-validation.yml b/.github/workflows/full-release-validation.yml index d7bf986c2f7b..c5906222687a 100644 --- a/.github/workflows/full-release-validation.yml +++ b/.github/workflows/full-release-validation.yml @@ -545,7 +545,11 @@ jobs: dispatch_id="full-release-validation-${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}-ci" dispatch_run_name="CI ${dispatch_id}" - dispatch_and_wait ci.yml "$dispatch_run_name" -f target_ref="$TARGET_SHA" -f include_android=true -f dispatch_id="$dispatch_id" + args=(-f target_ref="$TARGET_SHA" -f include_android=true -f dispatch_id="$dispatch_id") + if [[ "$TARGET_REF" =~ ^v[0-9]{4}\.[0-9]+\.[0-9]+(-(alpha|beta)\.[0-9]+)?$ ]]; then + args+=(-f historical_target_tag="$TARGET_REF") + fi + dispatch_and_wait ci.yml "$dispatch_run_name" "${args[@]}" plugin_prerelease: name: Run plugin prerelease validation diff --git a/test/scripts/ci-workflow-guards.test.ts b/test/scripts/ci-workflow-guards.test.ts index bd578ce859d2..9930adb5657e 100644 --- a/test/scripts/ci-workflow-guards.test.ts +++ b/test/scripts/ci-workflow-guards.test.ts @@ -45,6 +45,127 @@ function readCiWorkflow() { return parse(readFileSync(".github/workflows/ci.yml", "utf8")); } +function runCiManifestFixture(options: { + bundledPlanner: boolean; + eventName?: "pull_request" | "workflow_dispatch"; + historicalCompatibility?: boolean; + iosCapabilities?: boolean; +}) { + const root = mkdtempSync(path.join(tmpdir(), "openclaw-ci-manifest-")); + try { + const scriptsDir = path.join(root, "scripts", "lib"); + mkdirSync(scriptsDir, { recursive: true }); + writeFileSync( + path.join(scriptsDir, "ci-node-test-plan.mjs"), + options.bundledPlanner + ? ` + export const createNodeTestShards = () => [{ + checkName: "legacy-node-plan", + configs: ["test/vitest/legacy.config.ts"], + requiresDist: false, + runner: "ubuntu-24.04", + shardName: "legacy-node-plan", + }]; + export const createNodeTestShardBundles = () => [{ + checkName: "bundled-node-plan", + configs: ["test/vitest/bundled.config.ts"], + requiresDist: false, + runner: "ubuntu-24.04", + shardName: "bundled-node-plan", + }]; + ` + : ` + export const createNodeTestShards = () => [{ + checkName: "legacy-node-plan", + configs: ["test/vitest/legacy.config.ts"], + requiresDist: false, + runner: "ubuntu-24.04", + shardName: "legacy-node-plan", + }]; + `, + "utf8", + ); + const iosCapabilities = options.iosCapabilities ?? options.bundledPlanner; + const packageScripts = options.bundledPlanner + ? { + "android:i18n:check": "true", + "apple:i18n:check": "true", + ...(iosCapabilities ? { "ios:build": "true" } : {}), + "native:i18n:check": "true", + } + : {}; + writeFileSync( + path.join(root, "package.json"), + `${JSON.stringify({ scripts: packageScripts })}\n`, + ); + if (options.bundledPlanner) { + writeFileSync( + path.join(scriptsDir, "channel-contract-test-plan.mjs"), + `export const createChannelContractTestShards = () => [{ checkName: "channel-contracts" }];\n`, + ); + writeFileSync( + path.join(scriptsDir, "plugin-contract-test-plan.mjs"), + `export const createPluginContractTestShards = () => [{ checkName: "plugin-contracts" }];\n`, + ); + const smokePlan = path.join(root, "extensions", "qa-lab", "src", "ci-smoke-plan.ts"); + mkdirSync(path.dirname(smokePlan), { recursive: true }); + writeFileSync(smokePlan, "export {};\n"); + } + if (iosCapabilities) { + for (const name of ["install-swift-tools.sh", "lint-swift.sh", "format-swift.sh"]) { + writeFileSync(path.join(root, "scripts", name), "#!/bin/sh\n"); + } + } + const outputPath = path.join(root, "manifest.out"); + writeFileSync(outputPath, "", "utf8"); + const manifestStep = readCiWorkflow().jobs.preflight.steps.find( + (step: { name?: string }) => step.name === "Build CI manifest", + ); + const run = spawnSync("bash", ["-c", manifestStep.run], { + cwd: root, + encoding: "utf8", + env: { + ...process.env, + GITHUB_OUTPUT: outputPath, + OPENCLAW_CI_CHECKOUT_REVISION: "a".repeat(40), + OPENCLAW_CI_DOCS_CHANGED: "true", + OPENCLAW_CI_DOCS_ONLY: "false", + OPENCLAW_CI_EVENT_NAME: options.eventName ?? "workflow_dispatch", + OPENCLAW_CI_HISTORICAL_TARGET: + (options.historicalCompatibility ?? true) && + (options.eventName ?? "workflow_dispatch") === "workflow_dispatch" + ? "true" + : "false", + OPENCLAW_CI_REPOSITORY: "openclaw/openclaw", + OPENCLAW_CI_RUN_ANDROID: "true", + OPENCLAW_CI_RUN_CONTROL_UI_I18N: "true", + OPENCLAW_CI_RUN_IOS_BUILD: "true", + OPENCLAW_CI_RUN_MACOS: "true", + OPENCLAW_CI_RUN_NATIVE_I18N: "true", + OPENCLAW_CI_RUN_NODE: "true", + OPENCLAW_CI_RUN_NODE_FAST_CI_ROUTING: "false", + OPENCLAW_CI_RUN_NODE_FAST_ONLY: "false", + OPENCLAW_CI_RUN_NODE_FAST_PLUGIN_CONTRACTS: "false", + OPENCLAW_CI_RUN_SKILLS_PYTHON: "true", + OPENCLAW_CI_RUN_WINDOWS: "true", + OPENCLAW_CI_WORKFLOW_REVISION: "b".repeat(40), + }, + }); + const outputs = Object.fromEntries( + readFileSync(outputPath, "utf8") + .trim() + .split("\n") + .map((line) => { + const separator = line.indexOf("="); + return [line.slice(0, separator), line.slice(separator + 1)]; + }), + ); + return { output: `${run.stdout}${run.stderr}`, outputs, status: run.status }; + } finally { + rmSync(root, { force: true, recursive: true }); + } +} + function readAndroidReleaseWorkflow() { return parse(readFileSync(".github/workflows/android-release.yml", "utf8")); } @@ -1464,12 +1585,103 @@ describe("ci workflow guards", () => { ).run; expect(coverageStep.run).toBe("node scripts/check-protocol-event-coverage.mjs"); - expect(coverageStep.if).toContain("steps.manifest.outputs.run_node == 'true'"); - expect(coverageStep.if).toContain("steps.manifest.outputs.run_ios_build == 'true'"); - expect(coverageStep.if).toContain("steps.manifest.outputs.run_android_job == 'true'"); + expect(coverageStep.if).toBe("steps.manifest.outputs.run_protocol_event_coverage == 'true'"); expect(checkShardRun).not.toContain("check:protocol-coverage"); }); + it("uses target-owned CI plans and capabilities for older release checkouts", () => { + const legacy = runCiManifestFixture({ bundledPlanner: false }); + expect(legacy.status, legacy.output).toBe(0); + expect(legacy.outputs.historical_target).toBe("true"); + expect(legacy.outputs.run_ios_build).toBe("false"); + expect(legacy.outputs.run_native_i18n).toBe("false"); + expect(legacy.outputs.run_qa_smoke_ci).toBe("false"); + expect(legacy.outputs.run_channel_contracts_shards).toBe("false"); + expect(legacy.outputs.run_protocol_event_coverage).toBe("false"); + expect(JSON.parse(legacy.outputs.checks_node_core_nondist_matrix).include).toContainEqual( + expect.objectContaining({ + check_name: "legacy-node-plan", + shard_name: "legacy-node-plan", + }), + ); + + const current = runCiManifestFixture({ bundledPlanner: true }); + expect(current.status, current.output).toBe(0); + expect(current.outputs.run_ios_build).toBe("true"); + expect(current.outputs.run_native_i18n).toBe("true"); + expect(current.outputs.run_qa_smoke_ci).toBe("true"); + expect(current.outputs.run_channel_contracts_shards).toBe("true"); + expect(JSON.parse(current.outputs.checks_node_core_nondist_matrix).include).toContainEqual( + expect.objectContaining({ + check_name: "bundled-node-plan", + shard_name: "bundled-node-plan", + }), + ); + + const currentMissingIos = runCiManifestFixture({ + bundledPlanner: true, + eventName: "pull_request", + iosCapabilities: false, + }); + expect(currentMissingIos.status, currentMissingIos.output).toBe(0); + expect(currentMissingIos.outputs.historical_target).toBe("false"); + expect(currentMissingIos.outputs.run_ios_build).toBe("true"); + + const currentMissingPlanner = runCiManifestFixture({ + bundledPlanner: false, + eventName: "pull_request", + }); + expect(currentMissingPlanner.status).not.toBe(0); + expect(currentMissingPlanner.output).toContain( + "CI target does not export a supported Node test shard planner", + ); + + const alternateMissingPlanner = runCiManifestFixture({ + bundledPlanner: false, + historicalCompatibility: false, + }); + expect(alternateMissingPlanner.status).not.toBe(0); + expect(alternateMissingPlanner.output).toContain( + "CI target does not export a supported Node test shard planner", + ); + + const workflow = readCiWorkflow(); + const historicalTargetStep = workflow.jobs.preflight.steps.find( + (step: { name?: string }) => step.name === "Validate historical release target", + ); + expect(historicalTargetStep.if).toBe("inputs.historical_target_tag != ''"); + expect(historicalTargetStep.run).toContain('git ls-remote --tags "$remote"'); + expect(historicalTargetStep.run).toContain('[[ "$tag_sha" != "$EXPECTED_SHA" ]]'); + expect(workflow.jobs["qa-smoke-ci-profile"].if).toBe( + "needs.preflight.outputs.run_qa_smoke_ci == 'true'", + ); + expect(workflow.jobs["checks-fast-channel-contracts-shard"].if).toBe( + "needs.preflight.outputs.run_channel_contracts_shards == 'true'", + ); + const swiftInstall = workflow.jobs["macos-swift"].steps.find( + (step: { name?: string }) => step.name === "Install XcodeGen / SwiftLint / SwiftFormat", + ); + const swiftLint = workflow.jobs["macos-swift"].steps.find( + (step: { name?: string }) => step.name === "Swift lint", + ); + expect(swiftInstall.run).toContain("brew install xcodegen swiftlint swiftformat"); + expect(workflow.jobs["macos-swift"].env.HISTORICAL_TARGET).toBe( + "${{ needs.preflight.outputs.historical_target }}", + ); + expect(swiftInstall.run).toContain('elif [[ "$HISTORICAL_TARGET" == "true" ]]'); + expect(swiftLint.run).toContain("swiftlint lint --config config/swiftlint.yml"); + expect(swiftLint.run).toContain('elif [[ "$HISTORICAL_TARGET" == "true" ]]'); + + const checkShard = workflow.jobs["check-shard"].steps.find( + (step: { name?: string }) => step.name === "Run check shard", + ); + expect(checkShard.env.HISTORICAL_TARGET).toBe( + "${{ needs.preflight.outputs.historical_target }}", + ); + expect(checkShard.run).toContain("pnpm tsgo:scripts"); + expect(checkShard.run).toContain('elif [[ "$HISTORICAL_TARGET" != "true" ]]'); + }); + it("does not rebuild Control UI after build:ci-artifacts", () => { const workflow = readCiWorkflow(); const buildArtifactSteps = workflow.jobs["build-artifacts"].steps; diff --git a/test/scripts/plugin-prerelease-test-plan.test.ts b/test/scripts/plugin-prerelease-test-plan.test.ts index 95131801fa8e..645314af567e 100644 --- a/test/scripts/plugin-prerelease-test-plan.test.ts +++ b/test/scripts/plugin-prerelease-test-plan.test.ts @@ -327,6 +327,12 @@ describe("scripts/lib/plugin-prerelease-test-plan.mjs", () => { required: false, type: "boolean", }); + expect(workflow.on.workflow_dispatch.inputs.historical_target_tag).toEqual({ + default: "", + description: "Semver release tag authorizing compatibility fallbacks for its exact commit", + required: false, + type: "string", + }); expect(manifestEnv).toEqual({ OPENCLAW_CI_CHECKOUT_REVISION: "${{ steps.checkout_ref.outputs.sha }}", OPENCLAW_CI_DOCS_CHANGED: @@ -334,6 +340,7 @@ describe("scripts/lib/plugin-prerelease-test-plan.mjs", () => { OPENCLAW_CI_DOCS_ONLY: "${{ github.event_name == 'workflow_dispatch' && 'false' || steps.docs_scope.outputs.docs_only }}", OPENCLAW_CI_EVENT_NAME: "${{ github.event_name }}", + OPENCLAW_CI_HISTORICAL_TARGET: "${{ steps.historical_target.outputs.eligible || 'false' }}", OPENCLAW_CI_REPOSITORY: "${{ github.repository }}", OPENCLAW_CI_RUN_ANDROID: "${{ github.event_name == 'workflow_dispatch' && (inputs.release_gate || inputs.include_android) && 'true' || steps.changed_scope.outputs.run_android || 'false' }}", @@ -359,6 +366,7 @@ describe("scripts/lib/plugin-prerelease-test-plan.mjs", () => { "${{ github.event_name == 'workflow_dispatch' && 'true' || steps.changed_scope.outputs.run_ui_tests || 'false' }}", OPENCLAW_CI_RUN_WINDOWS: "${{ github.event_name == 'workflow_dispatch' && 'true' || steps.changed_scope.outputs.run_windows || 'false' }}", + OPENCLAW_CI_WORKFLOW_REVISION: "${{ github.sha }}", }); expect(manifestEnv).not.toHaveProperty("OPENCLAW_CI_FULL_RELEASE_VALIDATION"); expect(manifestScript).toContain("includeReleaseOnlyPluginShards: false"); @@ -375,9 +383,8 @@ describe("scripts/lib/plugin-prerelease-test-plan.mjs", () => { expect( workflow.jobs["check-shard"].steps.find((step) => step.name === "Run check shard").run, ).toContain("pnpm deadcode:ci"); - expect(normalCiScript).toContain( - 'dispatch_and_wait ci.yml "$dispatch_run_name" -f target_ref="$TARGET_SHA" -f include_android=true -f dispatch_id="$dispatch_id"', - ); + expect(normalCiScript).toContain('args+=(-f historical_target_tag="$TARGET_REF")'); + expect(normalCiScript).toContain('dispatch_and_wait ci.yml "$dispatch_run_name" "${args[@]}"'); expect(normalCiScript).not.toContain("full_release_validation=true"); expect(pluginPrereleaseScript).toContain( 'dispatch_and_wait plugin-prerelease.yml "$dispatch_run_name" -f target_ref="$TARGET_SHA" -f expected_sha="$TARGET_SHA" -f full_release_validation=true -f dispatch_id="$dispatch_id"',