From 043e4d1ec40c2e1350694fc4ffd9d607a49fb98c Mon Sep 17 00:00:00 2001 From: Dallin Romney Date: Wed, 15 Jul 2026 04:28:17 -0700 Subject: [PATCH] fix(release): validate frozen targets with current workflows (#108189) * fix(release): support frozen validation targets * fix(release): filter frozen-target upgrade scenarios --- .github/workflows/ci.yml | 20 ++++- .github/workflows/npm-telegram-beta-e2e.yml | 29 +++++-- .../openclaw-live-and-e2e-checks-reusable.yml | 1 + scripts/lib/docker-e2e-plan.d.mts | 1 + scripts/lib/docker-e2e-plan.mjs | 28 +++++- scripts/test-docker-all.mjs | 1 + test/scripts/ci-workflow-guards.test.ts | 14 ++- test/scripts/docker-e2e-plan.test.ts | 42 ++++++++- .../package-acceptance-workflow.test.ts | 86 +++++++++++++++++++ 9 files changed, 208 insertions(+), 14 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f2937b95b2df..08cef89af44f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1705,7 +1705,25 @@ jobs: OPENCLAW_VITEST_NO_OUTPUT_RETRY: "1" OPENCLAW_NODE_TEST_PLAN_CONCURRENCY: ${{ matrix.plan_concurrency }} shell: bash - run: node scripts/ci-run-node-test-shard.mjs + run: | + set -euo pipefail + runner="scripts/ci-run-node-test-shard.mjs" + if [[ ! -f "$runner" ]]; then + # Frozen release targets can predate the workflow-owned shard runner. + # Load only that runner from the exact trusted workflow commit while + # keeping its child tests rooted in the checked-out candidate. + harness_root="${RUNNER_TEMP}/openclaw-ci-shard-runner" + git fetch --no-tags --depth=1 origin "$GITHUB_SHA" + for file in \ + scripts/ci-run-node-test-shard.mjs \ + scripts/lib/direct-run.mjs \ + scripts/lib/local-heavy-check-runtime.mjs; do + mkdir -p "${harness_root}/$(dirname "$file")" + git show "${GITHUB_SHA}:${file}" > "${harness_root}/${file}" + done + runner="${harness_root}/${runner}" + fi + node "$runner" # Types, lint, and format check shards. check-shard: diff --git a/.github/workflows/npm-telegram-beta-e2e.yml b/.github/workflows/npm-telegram-beta-e2e.yml index a2621873e1a7..266d0b3cbed8 100644 --- a/.github/workflows/npm-telegram-beta-e2e.yml +++ b/.github/workflows/npm-telegram-beta-e2e.yml @@ -345,18 +345,33 @@ jobs: --arg attempt "$ARTIFACT_RUN_ATTEMPT" \ --arg run_id "$ARTIFACT_RUN_ID" \ '(.id | tostring) == $run_id and - (.run_attempt | tostring) == $attempt and - .status == "completed" and - .conclusion == "success"' \ + (.run_attempt | tostring) == $attempt' \ <<< "$attempt_json" >/dev/null || { echo "Package Telegram artifact producer run attempt does not match the requested tuple." >&2 exit 1 } attempt_started_at="$(jq -er '.run_started_at | fromdateiso8601' <<< "$attempt_json")" - attempt_completed_at="$(jq -er '.updated_at | fromdateiso8601' <<< "$attempt_json")" - if (( artifact_created_at <= attempt_started_at || artifact_created_at > attempt_completed_at )); then - echo "Package Telegram artifact creation time is outside the declared producer run attempt." >&2 - exit 1 + if [[ "$ARTIFACT_RUN_ID" == "$GITHUB_RUN_ID" ]]; then + jq -e '.status == "in_progress" and .conclusion == null' \ + <<< "$attempt_json" >/dev/null || { + echo "Current-run Package Telegram artifact is not from the active workflow attempt." >&2 + exit 1 + } + if (( artifact_created_at <= attempt_started_at )); then + echo "Package Telegram artifact predates the active producer run attempt." >&2 + exit 1 + fi + else + jq -e '.status == "completed" and .conclusion == "success"' \ + <<< "$attempt_json" >/dev/null || { + echo "Completed Package Telegram artifact producer run attempt was not successful." >&2 + exit 1 + } + attempt_completed_at="$(jq -er '.updated_at | fromdateiso8601' <<< "$attempt_json")" + if (( artifact_created_at <= attempt_started_at || artifact_created_at > attempt_completed_at )); then + echo "Package Telegram artifact creation time is outside the declared producer run attempt." >&2 + exit 1 + fi fi - name: Download package-under-test artifact diff --git a/.github/workflows/openclaw-live-and-e2e-checks-reusable.yml b/.github/workflows/openclaw-live-and-e2e-checks-reusable.yml index d5df124c0de7..4c29d620d6b4 100644 --- a/.github/workflows/openclaw-live-and-e2e-checks-reusable.yml +++ b/.github/workflows/openclaw-live-and-e2e-checks-reusable.yml @@ -389,6 +389,7 @@ env: FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true" NODE_VERSION: "24.15.0" OPENCLAW_DOCKER_E2E_ALLOW_UNRELEASED_CHANGELOG: ${{ inputs.allow_unreleased_changelog }} + OPENCLAW_UPGRADE_SURVIVOR_TARGET_ROOT: ${{ github.workspace }} jobs: validate_selected_ref: diff --git a/scripts/lib/docker-e2e-plan.d.mts b/scripts/lib/docker-e2e-plan.d.mts index 63060c0dafde..a52af37162e7 100644 --- a/scripts/lib/docker-e2e-plan.d.mts +++ b/scripts/lib/docker-e2e-plan.d.mts @@ -30,6 +30,7 @@ export type DockerE2ePlanOptions = { timingStore?: unknown; upgradeSurvivorBaselines?: string; upgradeSurvivorScenarios?: string; + upgradeSurvivorTargetRoot?: string; }; export type DockerE2ePlan = { diff --git a/scripts/lib/docker-e2e-plan.mjs b/scripts/lib/docker-e2e-plan.mjs index db8c9c3d7bd1..293b78658521 100644 --- a/scripts/lib/docker-e2e-plan.mjs +++ b/scripts/lib/docker-e2e-plan.mjs @@ -1,6 +1,8 @@ // Docker E2E scheduler planning helpers. // This module turns the scenario catalog plus env-driven inputs into a concrete // lane plan. It intentionally does not define scenario commands. +import { existsSync, readFileSync } from "node:fs"; +import { resolve } from "node:path"; import { BUNDLED_PLUGIN_INSTALL_UNINSTALL_SHARDS, DEFAULT_LIVE_RETRIES, @@ -91,6 +93,18 @@ const UPGRADE_SURVIVOR_SCENARIO_ALIASES = new Map([ ["far-reaching", UPGRADE_SURVIVOR_SCENARIOS], ]); +function filterUpgradeSurvivorScenariosForTarget(scenarios, targetRoot) { + if (!targetRoot) { + return scenarios; + } + const assertionsFile = resolve(targetRoot, "scripts/e2e/lib/upgrade-survivor/assertions.mjs"); + if (!existsSync(assertionsFile)) { + return []; + } + const assertionsSource = readFileSync(assertionsFile, "utf8"); + return scenarios.filter((scenario) => assertionsSource.includes(JSON.stringify(scenario))); +} + export function normalizeUpgradeSurvivorBaselineSpec(raw) { const value = String(raw ?? "").trim(); if (!value) { @@ -195,9 +209,15 @@ function supportsUpgradeSurvivorAcpToolsBridge(baselineSpec) { return comparePublishedReleaseVersion(version, { year: 2026, month: 4, patch: 22 }) >= 0; } -function expandUpgradeSurvivorBaselineLanes(poolLanes, rawBaselineSpecs, rawScenarios = "") { +function expandUpgradeSurvivorBaselineLanes(poolLanes, rawBaselineSpecs, rawScenarios, targetRoot) { const baselineSpecs = parseUpgradeSurvivorBaselineSpecs(rawBaselineSpecs); - const scenarios = parseUpgradeSurvivorScenarios(rawScenarios); + // Trusted-current planners may know scenarios that a frozen target's Docker + // harness cannot seed or assert. Filter by the selected tree's concrete + // harness contract so validation never schedules an impossible target lane. + const scenarios = filterUpgradeSurvivorScenariosForTarget( + parseUpgradeSurvivorScenarios(rawScenarios), + targetRoot, + ); if (baselineSpecs.length === 0 && scenarios.length === 0) { return poolLanes; } @@ -434,6 +454,7 @@ export function resolveDockerE2ePlan(options) { unexpandedSelectableLanes, upgradeSurvivorBaselines, upgradeSurvivorScenarios, + options.upgradeSurvivorTargetRoot, ), ); const releaseLanes = @@ -443,6 +464,7 @@ export function resolveDockerE2ePlan(options) { allReleasePathLanes({ includeOpenWebUI: options.includeOpenWebUI, releaseProfile }), upgradeSurvivorBaselines, upgradeSurvivorScenarios, + options.upgradeSurvivorTargetRoot, ) : expandUpgradeSurvivorBaselineLanes( releasePathChunkLanes(options.releaseChunk, { @@ -451,6 +473,7 @@ export function resolveDockerE2ePlan(options) { }), upgradeSurvivorBaselines, upgradeSurvivorScenarios, + options.upgradeSurvivorTargetRoot, ) : undefined; const selectedLanes = @@ -468,6 +491,7 @@ export function resolveDockerE2ePlan(options) { [unexpandedLane], upgradeSurvivorBaselines, upgradeSurvivorScenarios, + options.upgradeSurvivorTargetRoot, ); } selectNamedLanes(selectableLanes, [selectedName], "OPENCLAW_DOCKER_ALL_LANES"); diff --git a/scripts/test-docker-all.mjs b/scripts/test-docker-all.mjs index 6df0a333b78c..b1279955e2fb 100644 --- a/scripts/test-docker-all.mjs +++ b/scripts/test-docker-all.mjs @@ -1524,6 +1524,7 @@ async function main() { timingStore, upgradeSurvivorBaselines: process.env.OPENCLAW_UPGRADE_SURVIVOR_BASELINE_SPECS, upgradeSurvivorScenarios: process.env.OPENCLAW_UPGRADE_SURVIVOR_SCENARIOS, + upgradeSurvivorTargetRoot: process.env.OPENCLAW_UPGRADE_SURVIVOR_TARGET_ROOT, }); if (scheduledLanes.length === 0) { throw new Error( diff --git a/test/scripts/ci-workflow-guards.test.ts b/test/scripts/ci-workflow-guards.test.ts index ffc31920063e..9fd4b4e5d3c9 100644 --- a/test/scripts/ci-workflow-guards.test.ts +++ b/test/scripts/ci-workflow-guards.test.ts @@ -2643,9 +2643,17 @@ describe("ci workflow guards", () => { expect(runStep.env.OPENCLAW_VITEST_NO_OUTPUT_RETRY).toBe("1"); expect(runStep.env.OPENCLAW_NODE_TEST_ENV_JSON).toBe("${{ toJson(matrix.env) }}"); expect(runStep.env.OPENCLAW_NODE_TEST_TARGETS_JSON).toBe("${{ toJson(matrix.targets) }}"); - // Shard execution policy lives in the unit-tested wrapper script, not in - // inline workflow JavaScript. - expect(runStep.run).toBe("node scripts/ci-run-node-test-shard.mjs"); + // Shard execution policy lives in the unit-tested wrapper script. Frozen + // release targets load that wrapper from the exact trusted workflow SHA. + for (const expected of [ + 'runner="scripts/ci-run-node-test-shard.mjs"', + 'if [[ ! -f "$runner" ]]', + 'git fetch --no-tags --depth=1 origin "$GITHUB_SHA"', + 'git show "${GITHUB_SHA}:${file}" > "${harness_root}/${file}"', + 'node "$runner"', + ]) { + expect(runStep.run).toContain(expected); + } expect(existsSync("scripts/ci-run-node-test-shard.mjs")).toBe(true); }); diff --git a/test/scripts/docker-e2e-plan.test.ts b/test/scripts/docker-e2e-plan.test.ts index e9fface6b08d..6b343b31797a 100644 --- a/test/scripts/docker-e2e-plan.test.ts +++ b/test/scripts/docker-e2e-plan.test.ts @@ -1,6 +1,6 @@ // Docker E2E Plan tests cover docker e2e plan script behavior. import { execFileSync } from "node:child_process"; -import { copyFileSync, mkdirSync, readFileSync } from "node:fs"; +import { copyFileSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; import { dirname, join } from "node:path"; import { afterEach, describe, expect, it } from "vitest"; import { @@ -763,6 +763,46 @@ describe("scripts/lib/docker-e2e-plan", () => { ]); }); + it("omits trusted-current scenarios unsupported by a frozen target harness", () => { + const targetRoot = tempDirs.make("openclaw-frozen-upgrade-harness-"); + const assertionsFile = join(targetRoot, "scripts/e2e/lib/upgrade-survivor/assertions.mjs"); + mkdirSync(dirname(assertionsFile), { recursive: true }); + writeFileSync( + assertionsFile, + [ + "base", + "feishu-channel", + "bootstrap-persona", + "channel-post-core-restore", + "plugin-deps-cleanup", + "configured-plugin-installs", + "stale-source-plugin-shadow", + "tilde-log-path", + "versioned-runtime-deps", + ] + .map((scenario) => JSON.stringify(scenario)) + .join("\n"), + ); + const plan = planFor({ + selectedLaneNames: ["published-upgrade-survivor"], + upgradeSurvivorBaselines: "2026.6.11", + upgradeSurvivorScenarios: "reported-issues", + upgradeSurvivorTargetRoot: targetRoot, + }); + + expect(plan.lanes.map((lane) => lane.name)).toEqual([ + "published-upgrade-survivor-2026.6.11", + "published-upgrade-survivor-2026.6.11-feishu-channel", + "published-upgrade-survivor-2026.6.11-bootstrap-persona", + "published-upgrade-survivor-2026.6.11-channel-post-core-restore", + "published-upgrade-survivor-2026.6.11-plugin-deps-cleanup", + "published-upgrade-survivor-2026.6.11-configured-plugin-installs", + "published-upgrade-survivor-2026.6.11-stale-source-plugin-shadow", + "published-upgrade-survivor-2026.6.11-tilde-log-path", + "published-upgrade-survivor-2026.6.11-versioned-runtime-deps", + ]); + }); + it("skips plugin dependency cleanup for baselines without packaged plugin dirs", () => { const plan = planFor({ selectedLaneNames: ["published-upgrade-survivor"], diff --git a/test/scripts/package-acceptance-workflow.test.ts b/test/scripts/package-acceptance-workflow.test.ts index c389cdaacae4..6ac5a1b4c007 100644 --- a/test/scripts/package-acceptance-workflow.test.ts +++ b/test/scripts/package-acceptance-workflow.test.ts @@ -224,6 +224,65 @@ function runNpmTelegramInputValidation(overrides: Record) { }); } +function runNpmTelegramArtifactValidation(params: { + currentRunId: string; + producerRunId: string; + producerStatus: "completed" | "in_progress"; + producerConclusion: "success" | null; +}) { + const job = workflowJob(NPM_TELEGRAM_WORKFLOW, "run_package_telegram_e2e"); + const script = workflowStep(job, "Validate package artifact identity").run; + if (!script) { + throw new Error("Expected npm Telegram artifact identity script"); + } + const binDir = tempDirs.make("npm-telegram-artifact-gh-"); + const ghPath = `${binDir}/gh`; + writeFileSync( + ghPath, + `#!/bin/sh +case "$*" in + *actions/artifacts*) printf '%s\\n' "$MOCK_ARTIFACT_JSON" ;; + *actions/runs*) printf '%s\\n' "$MOCK_ATTEMPT_JSON" ;; + *) exit 2 ;; +esac +`, + ); + chmodSync(ghPath, 0o755); + const attempt = "2"; + const artifactId = "987"; + const artifactName = `package-under-test-${params.producerRunId}-${attempt}`; + const digest = "a".repeat(64); + return spawnSync("bash", ["-c", script], { + encoding: "utf8", + env: { + ARTIFACT_DIGEST: digest, + ARTIFACT_ID: artifactId, + ARTIFACT_NAME: artifactName, + ARTIFACT_RUN_ATTEMPT: attempt, + ARTIFACT_RUN_ID: params.producerRunId, + GITHUB_REPOSITORY: "openclaw/openclaw", + GITHUB_RUN_ID: params.currentRunId, + MOCK_ARTIFACT_JSON: JSON.stringify({ + created_at: "2026-07-15T08:49:20Z", + digest: `sha256:${digest}`, + expired: false, + id: Number(artifactId), + name: artifactName, + workflow_run: { id: Number(params.producerRunId) }, + }), + MOCK_ATTEMPT_JSON: JSON.stringify({ + conclusion: params.producerConclusion, + id: Number(params.producerRunId), + run_attempt: Number(attempt), + run_started_at: "2026-07-15T08:39:00Z", + status: params.producerStatus, + updated_at: "2026-07-15T08:49:30Z", + }), + PATH: `${binDir}:${process.env.PATH}`, + }, + }); +} + function runReleasePublishInputValidation(overrides: Record) { const job = workflowJob(RELEASE_PUBLISH_WORKFLOW, "resolve_release_target"); const script = workflowStep(job, "Validate inputs").run; @@ -1328,6 +1387,7 @@ describe("package artifact reuse", () => { expect(workflow).toContain( "OPENCLAW_UPGRADE_SURVIVOR_SCENARIOS: ${{ inputs.published_upgrade_survivor_scenarios }}", ); + expect(workflow).toContain("OPENCLAW_UPGRADE_SURVIVOR_TARGET_ROOT: ${{ github.workspace }}"); expect(workflow).toContain("Download current-run OpenClaw Docker E2E package"); expect(workflow).toContain("Download previous-run OpenClaw Docker E2E package"); expect(workflow).toContain("inputs.package_artifact_id != ''"); @@ -2782,6 +2842,10 @@ describe("package artifact reuse", () => { "actions/artifacts/${ARTIFACT_ID}", '--arg digest "sha256:${ARTIFACT_DIGEST}"', "actions/runs/${ARTIFACT_RUN_ID}/attempts/${ARTIFACT_RUN_ATTEMPT}", + 'if [[ "$ARTIFACT_RUN_ID" == "$GITHUB_RUN_ID" ]]', + '.status == "in_progress"', + ".conclusion == null", + "Package Telegram artifact predates the active producer run attempt.", '.status == "completed"', '.conclusion == "success"', "artifact_created_at <= attempt_started_at", @@ -2812,6 +2876,28 @@ describe("package artifact reuse", () => { ]); }); + it("accepts immutable artifacts produced earlier in the active workflow attempt", () => { + const result = runNpmTelegramArtifactValidation({ + currentRunId: "123", + producerConclusion: null, + producerRunId: "123", + producerStatus: "in_progress", + }); + + expect(result.status, result.stderr).toBe(0); + }); + + it("keeps completed external producer attempts success-gated", () => { + const result = runNpmTelegramArtifactValidation({ + currentRunId: "456", + producerConclusion: "success", + producerRunId: "123", + producerStatus: "completed", + }); + + expect(result.status, result.stderr).toBe(0); + }); + it("rejects partial npm Telegram artifact identity instead of falling back to npm", () => { const result = runNpmTelegramInputValidation({ PACKAGE_ARTIFACT_ID: "123",