diff --git a/docs/help/testing.md b/docs/help/testing.md index 605de2664078..718e987257de 100644 --- a/docs/help/testing.md +++ b/docs/help/testing.md @@ -824,7 +824,7 @@ The live-model Docker runners also bind-mount only the needed CLI auth homes (or - Release user journey smoke: `pnpm test:docker:release-user-journey` installs the packed OpenClaw tarball globally in a clean Docker home, runs onboarding, configures a mocked OpenAI provider, runs an agent turn, installs/uninstalls external plugins, configures ClickClack against a local fixture, verifies outbound/inbound messaging, restarts Gateway, and runs doctor. - Release typed onboarding smoke: `pnpm test:docker:release-typed-onboarding` installs the packed tarball, drives `openclaw onboard` through a real TTY, configures OpenAI as an env-ref provider, verifies no raw key persistence, and runs a mocked agent turn. - Release media/memory smoke: `pnpm test:docker:release-media-memory` installs the packed tarball, verifies image understanding from a PNG attachment, OpenAI-compatible image generation output, memory search recall, and recall survival across Gateway restart. -- Release upgrade user journey smoke: `pnpm test:docker:release-upgrade-user-journey` installs `openclaw@latest` by default, configures provider/plugin/ClickClack state on the published package, upgrades to the candidate tarball, then reruns the core agent/plugin/channel journey. Override the baseline with `OPENCLAW_RELEASE_UPGRADE_BASELINE_SPEC=openclaw@`. +- Release upgrade user journey smoke: `pnpm test:docker:release-upgrade-user-journey` installs the newest published baseline older than the candidate tarball by default, configures provider/plugin/ClickClack state on the published package, upgrades to the candidate tarball, then reruns the core agent/plugin/channel journey. If no older published baseline exists, it reuses the candidate version. Override the baseline with `OPENCLAW_RELEASE_UPGRADE_BASELINE_SPEC=openclaw@`. - Release plugin marketplace smoke: `pnpm test:docker:release-plugin-marketplace` installs from a local fixture marketplace, updates the installed plugin, uninstalls it, and verifies the plugin CLI disappears with install metadata pruned. - Skill install smoke: `pnpm test:docker:skill-install` installs the packed OpenClaw tarball globally in Docker, disables uploaded archive installs in config, resolves the current live ClawHub skill slug from search, installs it with `openclaw skills install`, and verifies the installed skill plus `.clawhub` origin/lock metadata. - Update channel switch smoke: `pnpm test:docker:update-channel-switch` installs the packed OpenClaw tarball globally in Docker, switches from package `stable` to git `dev`, verifies the persisted channel and plugin post-update work, then switches back to package `stable` and checks update status. diff --git a/scripts/e2e/lib/release-upgrade-user-journey/scenario.sh b/scripts/e2e/lib/release-upgrade-user-journey/scenario.sh index e9d067f08f32..f44157f3a953 100755 --- a/scripts/e2e/lib/release-upgrade-user-journey/scenario.sh +++ b/scripts/e2e/lib/release-upgrade-user-journey/scenario.sh @@ -40,13 +40,17 @@ CLICKCLACK_SERVER_LOG="$LOG_DIR/clickclack-server.log" GATEWAY_LOG="$LOG_DIR/gateway.log" MOCK_REQUEST_LOG="$scenario_tmp/openai-requests.jsonl" CLICKCLACK_STATE="$scenario_tmp/clickclack.json" -BASELINE_SPEC="${OPENCLAW_RELEASE_UPGRADE_BASELINE_SPEC:-openclaw@latest}" export SUCCESS_MARKER MOCK_REQUEST_LOG CLICKCLACK_STATE candidate_version="$( tar -xOf "${OPENCLAW_CURRENT_PACKAGE_TGZ:?missing OPENCLAW_CURRENT_PACKAGE_TGZ}" package/package.json | node -e 'let raw = ""; process.stdin.setEncoding("utf8"); process.stdin.on("data", (chunk) => { raw += chunk; }); process.stdin.on("end", () => { process.stdout.write(JSON.parse(raw).version); });' )" +if [ -n "${OPENCLAW_RELEASE_UPGRADE_BASELINE_SPEC:-}" ]; then + BASELINE_SPEC="$OPENCLAW_RELEASE_UPGRADE_BASELINE_SPEC" +else + BASELINE_SPEC="$(node scripts/lib/release-upgrade-baseline.mjs --candidate-version "$candidate_version")" +fi mock_pid="" clickclack_pid="" diff --git a/scripts/e2e/release-upgrade-user-journey-docker.sh b/scripts/e2e/release-upgrade-user-journey-docker.sh index 0254ec4a9cd3..58c4c694fed1 100755 --- a/scripts/e2e/release-upgrade-user-journey-docker.sh +++ b/scripts/e2e/release-upgrade-user-journey-docker.sh @@ -24,11 +24,17 @@ docker_e2e_build_or_reuse "$IMAGE_NAME" release-upgrade-user-journey "$ROOT_DIR/ OPENCLAW_TEST_STATE_SCRIPT_B64="$(docker_e2e_test_state_shell_b64 release-upgrade-user-journey empty)" run_log="$(docker_e2e_run_log release-upgrade-user-journey)" +DOCKER_ENV_ARGS=( + -e COREPACK_ENABLE_DOWNLOAD_PROMPT=0 + -e "OPENCLAW_TEST_STATE_SCRIPT_B64=$OPENCLAW_TEST_STATE_SCRIPT_B64" +) +if [ -n "${OPENCLAW_RELEASE_UPGRADE_BASELINE_SPEC:-}" ]; then + DOCKER_ENV_ARGS+=(-e "OPENCLAW_RELEASE_UPGRADE_BASELINE_SPEC=$OPENCLAW_RELEASE_UPGRADE_BASELINE_SPEC") +fi + echo "Running release upgrade user journey Docker E2E..." if ! docker_e2e_run_with_harness \ - -e COREPACK_ENABLE_DOWNLOAD_PROMPT=0 \ - -e "OPENCLAW_TEST_STATE_SCRIPT_B64=$OPENCLAW_TEST_STATE_SCRIPT_B64" \ - -e "OPENCLAW_RELEASE_UPGRADE_BASELINE_SPEC=${OPENCLAW_RELEASE_UPGRADE_BASELINE_SPEC:-openclaw@latest}" \ + "${DOCKER_ENV_ARGS[@]}" \ "${DOCKER_E2E_PACKAGE_ARGS[@]}" \ -i "$IMAGE_NAME" bash scripts/e2e/lib/release-upgrade-user-journey/scenario.sh >"$run_log" 2>&1; then docker_e2e_print_log "$run_log" diff --git a/scripts/lib/release-upgrade-baseline.mjs b/scripts/lib/release-upgrade-baseline.mjs new file mode 100644 index 000000000000..d37cd5c95edd --- /dev/null +++ b/scripts/lib/release-upgrade-baseline.mjs @@ -0,0 +1,115 @@ +import { execFileSync } from "node:child_process"; +import { readFileSync } from "node:fs"; +import { fileURLToPath } from "node:url"; +import { parseReleaseVersion } from "./npm-publish-plan.mjs"; + +function parseVersion(version) { + return parseReleaseVersion(String(version ?? "").trim()) ?? undefined; +} + +export function compareOpenClawVersions(leftVersion, rightVersion) { + const left = parseVersion(leftVersion); + const right = parseVersion(rightVersion); + if (!left || !right) { + throw new Error(`cannot compare OpenClaw versions: ${leftVersion} ${rightVersion}`); + } + for (const key of ["year", "month", "patch"]) { + const delta = left[key] - right[key]; + if (delta !== 0) { + return delta; + } + } + const channelRank = { alpha: 0, beta: 1, stable: 2 }; + const channelDelta = channelRank[left.channel] - channelRank[right.channel]; + if (channelDelta !== 0) { + return channelDelta; + } + if (left.channel === "alpha") { + return (left.alphaNumber ?? 0) - (right.alphaNumber ?? 0); + } + if (left.channel === "beta") { + return (left.betaNumber ?? 0) - (right.betaNumber ?? 0); + } + return (left.correctionNumber ?? 0) - (right.correctionNumber ?? 0); +} + +function normalizePublishedVersions(publishedVersions) { + return [...new Set(publishedVersions.map((version) => String(version).trim()).filter(Boolean))] + .filter((version) => parseVersion(version)) + .toSorted((left, right) => compareOpenClawVersions(right, left)); +} + +export function resolveDefaultReleaseUpgradeBaseline(candidateVersion, publishedVersions) { + const candidate = parseVersion(candidateVersion); + if (!candidate) { + throw new Error(`invalid candidate OpenClaw version: ${candidateVersion}`); + } + + const versions = normalizePublishedVersions(publishedVersions); + const older = versions.find((version) => compareOpenClawVersions(version, candidate.version) < 0); + if (older) { + return `openclaw@${older}`; + } + + const same = versions.find( + (version) => compareOpenClawVersions(version, candidate.version) === 0, + ); + if (same) { + return `openclaw@${same}`; + } + + throw new Error(`no published OpenClaw baseline is <= candidate ${candidate.version}`); +} + +function parseArgs(argv) { + const args = new Map(); + for (let index = 0; index < argv.length; index += 1) { + const arg = argv[index]; + if (!arg.startsWith("--")) { + throw new Error(`unexpected argument: ${arg}`); + } + const key = arg.slice(2); + const value = argv[index + 1]; + if (value === undefined || value.startsWith("--")) { + throw new Error(`missing value for --${key}`); + } + args.set(key, value); + index += 1; + } + return args; +} + +function readPublishedVersions(args) { + const versionsJson = args.get("versions-json"); + if (versionsJson) { + const parsed = JSON.parse(readFileSync(versionsJson, "utf8")); + if (!Array.isArray(parsed)) { + throw new Error(`npm versions list must be a JSON array: ${versionsJson}`); + } + return parsed; + } + const raw = execFileSync("npm", ["view", "openclaw", "versions", "--json", "--silent"], { + encoding: "utf8", + stdio: ["ignore", "pipe", "inherit"], + }); + const parsed = JSON.parse(raw); + if (!Array.isArray(parsed)) { + throw new Error("npm returned a non-array openclaw versions payload"); + } + return parsed; +} + +const isMain = process.argv[1] ? fileURLToPath(import.meta.url) === process.argv[1] : false; + +if (isMain) { + const args = parseArgs(process.argv.slice(2)); + const candidateVersion = args.get("candidate-version"); + if (!candidateVersion) { + throw new Error("--candidate-version is required"); + } + const baseline = resolveDefaultReleaseUpgradeBaseline( + candidateVersion, + readPublishedVersions(args), + ); + process.stdout.write(`${baseline}\n`); +} diff --git a/test/scripts/release-upgrade-baseline.test.ts b/test/scripts/release-upgrade-baseline.test.ts new file mode 100644 index 000000000000..6b4cb508144f --- /dev/null +++ b/test/scripts/release-upgrade-baseline.test.ts @@ -0,0 +1,64 @@ +import { describe, expect, it } from "vitest"; +import { + compareOpenClawVersions, + resolveDefaultReleaseUpgradeBaseline, +} from "../../scripts/lib/release-upgrade-baseline.mjs"; + +describe("release upgrade baseline resolver", () => { + it("prefers the newest published baseline older than the candidate across channels", () => { + expect( + resolveDefaultReleaseUpgradeBaseline("2026.6.2", [ + "2026.5.30", + "2026.6.2", + "2026.6.6", + "2026.6.2-beta.1", + "2026.6.1", + ]), + ).toBe("openclaw@2026.6.2-beta.1"); + expect(resolveDefaultReleaseUpgradeBaseline("2026.6.7", ["2026.6.6", "2026.6.7-beta.2"])).toBe( + "openclaw@2026.6.7-beta.2", + ); + }); + + it("uses prerelease baselines only when no stable baseline can satisfy the candidate", () => { + expect( + resolveDefaultReleaseUpgradeBaseline("2026.6.2-beta.2", ["2026.6.2", "2026.6.2-beta.1"]), + ).toBe("openclaw@2026.6.2-beta.1"); + }); + + it("prefers older prerelease baselines over same-version stable baselines", () => { + expect(resolveDefaultReleaseUpgradeBaseline("2026.6.2", ["2026.6.2", "2026.6.1-beta.1"])).toBe( + "openclaw@2026.6.1-beta.1", + ); + }); + + it("treats numeric correction releases as stable baselines", () => { + expect(resolveDefaultReleaseUpgradeBaseline("2026.5.3-1", ["2026.5.2", "2026.5.3"])).toBe( + "openclaw@2026.5.3", + ); + expect( + resolveDefaultReleaseUpgradeBaseline("2026.5.3-2", ["2026.5.2", "2026.5.3", "2026.5.3-1"]), + ).toBe("openclaw@2026.5.3-1"); + }); + + it("falls back to the candidate version when no older baseline exists", () => { + expect(resolveDefaultReleaseUpgradeBaseline("2026.6.2", ["2026.6.2", "2026.6.6"])).toBe( + "openclaw@2026.6.2", + ); + }); + + it("does not pick a newer stable release for a prerelease candidate", () => { + expect( + resolveDefaultReleaseUpgradeBaseline("2026.6.7-beta.1", [ + "2026.6.6", + "2026.6.7", + "2026.6.7-beta.2", + ]), + ).toBe("openclaw@2026.6.6"); + }); + + it("compares prerelease versions with semver ordering", () => { + expect(compareOpenClawVersions("2026.6.7-beta.2", "2026.6.7-beta.10")).toBeLessThan(0); + expect(compareOpenClawVersions("2026.6.7", "2026.6.7-beta.10")).toBeGreaterThan(0); + }); +});