mirror of
https://github.com/openclaw/openclaw.git
synced 2026-07-21 10:16:44 +00:00
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:
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -30,6 +30,7 @@ export type DockerE2ePlanOptions = {
|
||||
timingStore?: unknown;
|
||||
upgradeSurvivorBaselines?: string;
|
||||
upgradeSurvivorScenarios?: string;
|
||||
upgradeSurvivorTargetRoot?: string;
|
||||
};
|
||||
|
||||
export type DockerE2ePlan = {
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user