fix(release): validate frozen targets with current workflows (#108189)

* fix(release): support frozen validation targets

* fix(release): filter frozen-target upgrade scenarios
This commit is contained in:
Dallin Romney
2026-07-15 04:28:17 -07:00
committed by GitHub
parent 20d6ddc4c7
commit 043e4d1ec4
9 changed files with 208 additions and 14 deletions
+19 -1
View File
@@ -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:
+22 -7
View File
@@ -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
@@ -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:
+1
View File
@@ -30,6 +30,7 @@ export type DockerE2ePlanOptions = {
timingStore?: unknown;
upgradeSurvivorBaselines?: string;
upgradeSurvivorScenarios?: string;
upgradeSurvivorTargetRoot?: string;
};
export type DockerE2ePlan = {
+26 -2
View File
@@ -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");
+1
View File
@@ -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(
+11 -3
View File
@@ -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);
});
+41 -1
View File
@@ -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"],
@@ -224,6 +224,65 @@ function runNpmTelegramInputValidation(overrides: Record<string, string>) {
});
}
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<string, string>) {
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",