diff --git a/.github/workflows/e2e.yaml b/.github/workflows/e2e.yaml index 6e9a43d717a..110992ecd2c 100644 --- a/.github/workflows/e2e.yaml +++ b/.github/workflows/e2e.yaml @@ -3509,24 +3509,34 @@ jobs: name: OpenShell gateway upgrade (${{ matrix.legacy.id }}) needs: generate-matrix if: ${{ (github.event_name != 'workflow_dispatch' || (inputs.jobs == '' && inputs.targets == '')) || contains(format(',{0},', inputs.jobs), ',openshell-gateway-upgrade,') || contains(format(',{0},', inputs.targets), ',openshell-gateway-upgrade,') }} - runs-on: ubuntu-latest + runs-on: ${{ matrix.legacy.runner }} timeout-minutes: 60 strategy: fail-fast: false matrix: legacy: - - id: v0.0.36 + - id: v0.0.36-x86_64 + runner: ubuntu-latest nemoclaw_ref: v0.0.36 nemoclaw_commit: "3351fbdd4eb7d9b80ec471545083956327da2b10" installer_sha256: "0c42400a0d3867739f1d75d612e069967be4506e169974bbbebf14b7af39144f" sandbox_base_image_ref: "ghcr.io/nvidia/nemoclaw/sandbox-base@sha256:104151ffadc2ff0b6c815e3c95c2783ced61aee0d0f83fc327cc02be9b7e14e6" openshell_version: 0.0.36 openclaw_version: 2026.4.24 - - id: v0.0.55 + - id: v0.0.55-x86_64 + runner: ubuntu-latest nemoclaw_ref: v0.0.55 nemoclaw_commit: "95d483fe2b6569d68e59493c60f19df09a068e8f" installer_sha256: "ff8cf448e4d17b00421545a1f333262b615b1b0aa236d0cc5aeaf4e2cae2d897" - sandbox_base_image_ref: "ghcr.io/nvidia/nemoclaw/sandbox-base@sha256:104151ffadc2ff0b6c815e3c95c2783ced61aee0d0f83fc327cc02be9b7e14e6" + sandbox_base_image_ref: "ghcr.io/nvidia/nemoclaw/sandbox-base@sha256:10433a8cd2f2b809dd0fdf983514679e04c0f8aa1ff5bbff675029046033b108" + openshell_version: 0.0.44 + openclaw_version: 2026.5.22 + - id: v0.0.55-aarch64 + runner: ubuntu-24.04-arm + nemoclaw_ref: v0.0.55 + nemoclaw_commit: "95d483fe2b6569d68e59493c60f19df09a068e8f" + installer_sha256: "ff8cf448e4d17b00421545a1f333262b615b1b0aa236d0cc5aeaf4e2cae2d897" + sandbox_base_image_ref: "ghcr.io/nvidia/nemoclaw/sandbox-base@sha256:10433a8cd2f2b809dd0fdf983514679e04c0f8aa1ff5bbff675029046033b108" openshell_version: 0.0.44 openclaw_version: 2026.5.22 env: @@ -3557,9 +3567,8 @@ jobs: uses: NVIDIA/NemoClaw/.github/actions/prepare-e2e@50281ee84c4a6fc759da95ea28fc0b7d9c378a28 - name: Run OpenShell gateway upgrade live Vitest test - # This preserves the legacy ubuntu-latest + Docker/OpenShell runner - # lane while covering both the original v0.0.36 fixture and the exact - # v0.0.55/OpenShell 0.0.44 source shape from upgrade regression #6114. + # Keep the original v0.0.36 fixture on x86_64 and validate the exact + # v0.0.55/OpenShell 0.0.44 regression shape on x86_64 and arm64. env: GITHUB_TOKEN: ${{ github.token }} run: | diff --git a/scripts/install.sh b/scripts/install.sh index 591cc4cad69..fa53baaae3d 100755 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -2201,6 +2201,13 @@ preinstall_backup_and_retire_legacy_gateway() { fi _PREEXISTING_SANDBOX_COUNT="$sandbox_count" [ "$sandbox_count" -gt 0 ] 2>/dev/null || return 0 + if ! command_exists openshell; then + # NemoClaw v0.0.55's OpenShell 0.0.44 layout could install this binary + # without persisting ~/.local/bin on PATH. Retain this fallback while direct + # v0.0.55 upgrades are supported; remove it only after support for that + # source version and its regression fixture are retired together. + prefer_user_local_openshell + fi command_exists openshell || return 0 if [[ "${NEMOCLAW_SINGLE_SESSION:-}" == "1" ]]; then diff --git a/test/e2e/live/openshell-gateway-upgrade-helpers.ts b/test/e2e/live/openshell-gateway-upgrade-helpers.ts index 54b50102c36..afd76362105 100644 --- a/test/e2e/live/openshell-gateway-upgrade-helpers.ts +++ b/test/e2e/live/openshell-gateway-upgrade-helpers.ts @@ -3,7 +3,7 @@ import { shellQuote } from "../fixtures/clients/command.ts"; -const COMMON_INSTALLER_ARGS = ["--non-interactive", "--yes-i-accept-third-party-software"]; +const NON_INTERACTIVE_INSTALLER_ARGS = ["--non-interactive", "--yes-i-accept-third-party-software"]; const GATEWAY_VOLUME_PREFIX = "openshell-cluster-nemoclaw"; export interface LegacyGatewayUpgradeFixture { @@ -47,11 +47,14 @@ export function validateLegacyGatewayUpgradeFixture(fixture: LegacyGatewayUpgrad } export function oldGatewayUpgradeInstallerArgs(installer: string): string[] { - return [installer, ...COMMON_INSTALLER_ARGS, "--fresh"]; + return [installer, ...NON_INTERACTIVE_INSTALLER_ARGS, "--fresh"]; } -export function currentGatewayUpgradeInstallerArgs(installer: string): string[] { - return [installer, ...COMMON_INSTALLER_ARGS]; +export function currentGatewayUpgradeInstallerArgs( + installer: string, + options: { interactive?: boolean } = {}, +): string[] { + return options.interactive ? [installer] : [installer, ...NON_INTERACTIVE_INSTALLER_ARGS]; } export function upgradeGatewayStateCleanupScript(pidFile: string): string { diff --git a/test/e2e/live/openshell-gateway-upgrade.test.ts b/test/e2e/live/openshell-gateway-upgrade.test.ts index 4b256ceb007..cc8e4a9ef5d 100644 --- a/test/e2e/live/openshell-gateway-upgrade.test.ts +++ b/test/e2e/live/openshell-gateway-upgrade.test.ts @@ -106,8 +106,13 @@ function liveEnv(extra: NodeJS.ProcessEnv = {}): NodeJS.ProcessEnv { }; } -function shellLoginPrefix(): string { - return [ +function withoutEnvKeys(env: NodeJS.ProcessEnv, keys: readonly string[]): NodeJS.ProcessEnv { + const excluded = new Set(keys); + return Object.fromEntries(Object.entries(env).filter(([key]) => !excluded.has(key))); +} + +function shellLoginPrefix(hideUserLocalOpenShell = false): string { + const lines = [ "set -euo pipefail", 'if [ -f "$HOME/.bashrc" ]; then', " # shellcheck source=/dev/null", @@ -118,8 +123,22 @@ function shellLoginPrefix(): string { " # shellcheck source=/dev/null", ' . "$NVM_DIR/nvm.sh"', "fi", - 'export PATH="$HOME/.local/bin:$PATH"', - ].join("\n"); + ]; + lines.push( + ...(hideUserLocalOpenShell + ? [ + '_path_without_user_local=""', + "while IFS= read -r _path_entry; do", + ' [ "$_path_entry" = "$HOME/.local/bin" ] && continue', + ' _path_without_user_local="${_path_without_user_local:+${_path_without_user_local}:}${_path_entry}"', + 'done < <(tr ":" "\\n" <<<"$PATH")', + 'export PATH="$_path_without_user_local"', + "unset _path_without_user_local _path_entry", + "hash -r", + ] + : ['export PATH="$HOME/.local/bin:$PATH"']), + ); + return lines.join("\n"); } function expectOutputContains(result: ShellProbeResult, value: string, label: string): void { @@ -147,16 +166,21 @@ async function bash( env?: NodeJS.ProcessEnv; timeoutMs?: number; cwd?: string; + hideUserLocalOpenShell?: boolean; redactionValues?: string[]; }, ): Promise { - return host.command("bash", ["-lc", `${shellLoginPrefix()}\n${script}`], { - cwd: options.cwd ?? REPO_ROOT, - artifactName: options.artifactName, - env: options.env ?? liveEnv(), - redactionValues: options.redactionValues, - timeoutMs: options.timeoutMs ?? OPENSHELL_TIMEOUT_MS, - }); + return host.command( + "bash", + ["-lc", `${shellLoginPrefix(options.hideUserLocalOpenShell)}\n${script}`], + { + cwd: options.cwd ?? REPO_ROOT, + artifactName: options.artifactName, + env: options.env ?? liveEnv(), + redactionValues: options.redactionValues, + timeoutMs: options.timeoutMs ?? OPENSHELL_TIMEOUT_MS, + }, + ); } // The frozen release installers are the source of truth, but their embedded @@ -368,15 +392,33 @@ async function runInstallerPayload( logFile: string, env: NodeJS.ProcessEnv, redactionValues: string[] = [], + options: { hideUserLocalOpenShell?: boolean; interactiveInput?: string } = {}, ): Promise { const quotedInstallerArgs = installerArgs.map(shellQuote).join(" "); + const installerCommand = `bash ${quotedInstallerArgs} >${shellQuote(logFile)} 2>&1`; + // The live command runner closes stdin. util-linux `script` supplies the + // /dev/tty that the ordinary curl|bash confirmation path expects. + const installerInvocation = options.interactiveInput + ? `printf '%s\\n' ${shellQuote(options.interactiveInput)} | script --quiet --return --command ${shellQuote(installerCommand)} /dev/null` + : installerCommand; + const hiddenOpenShellPreflight = options.hideUserLocalOpenShell + ? [ + 'test -x "$HOME/.local/bin/openshell"', + "if command -v openshell >/dev/null 2>&1; then", + ' echo "Expected the v0.0.55 user-local OpenShell binary to be absent from PATH" >&2', + " exit 1", + "fi", + ].join("\n") + : ""; const result = await bash( host, - `rm -f ${shellQuote(logFile)} -bash ${quotedInstallerArgs} >${shellQuote(logFile)} 2>&1`, + `${hiddenOpenShellPreflight} +rm -f ${shellQuote(logFile)} +${installerInvocation}`, { artifactName: `${label.replace(/[^a-z0-9_.-]+/gi, "-")}-installer`, env, + hideUserLocalOpenShell: options.hideUserLocalOpenShell, redactionValues, timeoutMs: INSTALL_TIMEOUT_MS, }, @@ -576,11 +618,10 @@ async function installCurrentNemoclawUpgrade( expectExitZero(currentRefResult, "resolve current NemoClaw ref"); const resolvedRef = currentRefResult.stdout.trim(); expect(resolvedRef.length).toBeGreaterThan(0); - const currentEnv = liveEnv({ + const exerciseOrdinaryUpgrade = OLD_NEMOCLAW_REF === "v0.0.55"; + const baseCurrentEnv = liveEnv({ COMPATIBLE_API_KEY: "dummy", GITHUB_TOKEN: process.env.GITHUB_TOKEN ?? "", - NEMOCLAW_ACCEPT_EXPERIMENTAL_OPENSHELL_UPGRADE: "1", - NEMOCLAW_CONFIRM_LEGACY_MANAGED_RECREATE: JSON.stringify([SURVIVOR_SANDBOX]), NEMOCLAW_BOOTSTRAP_PAYLOAD: "1", NEMOCLAW_INSTALL_REF: resolvedRef, NEMOCLAW_INSTALL_TAG: resolvedRef, @@ -592,18 +633,44 @@ async function installCurrentNemoclawUpgrade( NEMOCLAW_DASHBOARD_PORT: "", CHAT_UI_URL: "", }); + const currentEnv = exerciseOrdinaryUpgrade + ? withoutEnvKeys(baseCurrentEnv, [ + "ACCEPT_THIRD_PARTY_SOFTWARE", + "NON_INTERACTIVE", + "NEMOCLAW_ACCEPT_THIRD_PARTY_SOFTWARE", + "NEMOCLAW_NON_INTERACTIVE", + "NEMOCLAW_ACCEPT_EXPERIMENTAL_OPENSHELL_UPGRADE", + "NEMOCLAW_CONFIRM_LEGACY_MANAGED_RECREATE", + ]) + : { + ...baseCurrentEnv, + NEMOCLAW_ACCEPT_EXPERIMENTAL_OPENSHELL_UPGRADE: "1", + NEMOCLAW_CONFIRM_LEGACY_MANAGED_RECREATE: JSON.stringify([SURVIVOR_SANDBOX]), + }; const redactionValues = [process.env.GITHUB_TOKEN ?? ""].filter(Boolean); await runInstallerPayload( host, `current-${resolvedRef.slice(0, 12)}`, - currentGatewayUpgradeInstallerArgs(path.join(REPO_ROOT, "scripts", "install.sh")), + currentGatewayUpgradeInstallerArgs(path.join(REPO_ROOT, "scripts", "install.sh"), { + interactive: exerciseOrdinaryUpgrade, + }), currentInstallLog, currentEnv, redactionValues, + { + hideUserLocalOpenShell: exerciseOrdinaryUpgrade, + // One answer covers a changed usage notice, when present, and the other + // confirms the legacy managed-image recovery prompt. + interactiveInput: exerciseOrdinaryUpgrade ? "yes\nyes" : undefined, + }, ); const currentLog = fs.readFileSync(currentInstallLog, "utf8"); - expect(currentLog).toContain("Confirmed 1 exact pre-fingerprint sandbox name(s)"); + expect(currentLog).toContain( + exerciseOrdinaryUpgrade + ? "Confirmed legacy managed-image recovery" + : "Confirmed 1 exact pre-fingerprint sandbox name(s)", + ); expect(currentLog).toContain("Pre-upgrade backup: 1 backed up, 0 failed, 0 skipped"); expect(currentLog).toContain("Existing sandboxes recovered; skipping generic onboarding"); diff --git a/test/e2e/support/openshell-gateway-upgrade-workflow-boundary.test.ts b/test/e2e/support/openshell-gateway-upgrade-workflow-boundary.test.ts index 8aa110bdb49..98200431437 100644 --- a/test/e2e/support/openshell-gateway-upgrade-workflow-boundary.test.ts +++ b/test/e2e/support/openshell-gateway-upgrade-workflow-boundary.test.ts @@ -6,6 +6,14 @@ import fs from "node:fs"; import os from "node:os"; import path from "node:path"; import { describe, expect, it } from "vitest"; +import { + readOpenShellGatewayUpgradeWorkflow, + validateOpenShellGatewayUpgradeWorkflow, +} from "../../../tools/e2e/openshell-gateway-upgrade-workflow-boundary.mts"; +import { + validateE2eWorkflow, + validateE2eWorkflowBoundary, +} from "../../../tools/e2e/workflow-boundary.mts"; import { currentGatewayUpgradeInstallerArgs, oldGatewayUpgradeInstallerArgs, @@ -14,6 +22,34 @@ import { } from "../live/openshell-gateway-upgrade-helpers.ts"; describe("OpenShell gateway upgrade workflow boundary", () => { + it("pins the v0.0.55 x86_64 and arm64 fixtures to the canonical live test (#6114)", () => { + const workflow = readOpenShellGatewayUpgradeWorkflow(); + expect(validateOpenShellGatewayUpgradeWorkflow(workflow)).toEqual([]); + expect(validateE2eWorkflowBoundary()).toEqual([]); + + const job = (workflow.jobs as Record>)[ + "openshell-gateway-upgrade" + ]; + job["runs-on"] = "ubuntu-latest"; + const strategy = job.strategy as Record>; + const legacy = strategy.matrix.legacy as Array>; + legacy.find((fixture) => fixture.id === "v0.0.55-x86_64")!.sandbox_base_image_ref = + "ghcr.io/nvidia/nemoclaw/sandbox-base@sha256:104151ffadc2ff0b6c815e3c95c2783ced61aee0d0f83fc327cc02be9b7e14e6"; + legacy.find((fixture) => fixture.id === "v0.0.55-aarch64")!.runner = "ubuntu-latest"; + const run = (job.steps as Array>).find( + (step) => step.name === "Run OpenShell gateway upgrade live Vitest test", + )!; + run.run = "npx vitest run --project e2e-live unrelated.test.ts"; + + expect(validateE2eWorkflow(workflow)).toEqual( + expect.arrayContaining([ + "openshell-gateway-upgrade must run on ${{ matrix.legacy.runner }}", + "openshell-gateway-upgrade v0.0.55 matrix must pin x86_64 and arm64 upgrade fixtures", + "openshell-gateway-upgrade step 'Run OpenShell gateway upgrade live Vitest test' must run: npx tsx tools/e2e/live-vitest-invocation.mts run --test-path test/e2e/live/openshell-gateway-upgrade.test.ts", + ]), + ); + }); + it("freshens only the retryable old fixture install", () => { expect(oldGatewayUpgradeInstallerArgs("old-install.sh")).toEqual([ "old-install.sh", @@ -26,6 +62,9 @@ describe("OpenShell gateway upgrade workflow boundary", () => { "--non-interactive", "--yes-i-accept-third-party-software", ]); + expect(currentGatewayUpgradeInstallerArgs("current-install.sh", { interactive: true })).toEqual( + ["current-install.sh"], + ); }); it("rejects mutable or injectable historical fixture inputs before use (#6114)", () => { @@ -35,11 +74,11 @@ describe("OpenShell gateway upgrade workflow boundary", () => { installerSha256: "ff8cf448e4d17b00421545a1f333262b615b1b0aa236d0cc5aeaf4e2cae2d897", openclawVersion: "2026.5.22", sandboxBaseImageRef: - "ghcr.io/nvidia/nemoclaw/sandbox-base@sha256:104151ffadc2ff0b6c815e3c95c2783ced61aee0d0f83fc327cc02be9b7e14e6", + "ghcr.io/nvidia/nemoclaw/sandbox-base@sha256:10433a8cd2f2b809dd0fdf983514679e04c0f8aa1ff5bbff675029046033b108", }; expect(validateLegacyGatewayUpgradeFixture(fixture)).toEqual({ - sandboxBaseDigest: "104151ffadc2ff0b6c815e3c95c2783ced61aee0d0f83fc327cc02be9b7e14e6", + sandboxBaseDigest: "10433a8cd2f2b809dd0fdf983514679e04c0f8aa1ff5bbff675029046033b108", }); expect(() => validateLegacyGatewayUpgradeFixture({ diff --git a/test/install-openshell-upgrade-prompt.test.ts b/test/install-openshell-upgrade-prompt.test.ts index 59969cb185e..5ec5cf5f373 100644 --- a/test/install-openshell-upgrade-prompt.test.ts +++ b/test/install-openshell-upgrade-prompt.test.ts @@ -20,8 +20,10 @@ function runPreinstallUpgradeGuard( currentBackupSucceeds?: boolean; currentCliAvailable?: boolean; hasOldCli?: boolean; + openshellOnPath?: boolean; openshellVersion?: string; registryJson?: string; + userLocalOpenshell?: boolean; } = {}, ) { const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-openshell-upgrade-prompt-")); @@ -66,6 +68,18 @@ fi exit 0 `, ); + const openshellScript = `#!/usr/bin/env bash +printf '%s\\n' "$*" >> "${openshellLog}" +exit 0 +`; + const openshellTargets = [ + options.openshellOnPath !== false ? path.join(bin, "openshell") : null, + options.userLocalOpenshell === true ? path.join(home, ".local", "bin", "openshell") : null, + ].filter((target): target is string => target !== null); + for (const target of openshellTargets) { + fs.mkdirSync(path.dirname(target), { recursive: true }); + writeExecutable(target, openshellScript); + } writeExecutable(path.join(bin, "python3"), "#!/usr/bin/env bash\nexit 127\n"); const resolveCli = @@ -78,7 +92,6 @@ exit 0 warn() { printf '[WARN] %s\\n' "$*"; } _CLI_BIN=nemoclaw HOME="${home}" - command_exists() { [ "$1" = "openshell" ]; } installed_openshell_version() { printf '${openshellVersion}'; } resolve_existing_cli_runner() { ${resolveCli}; } prepare_current_cli_for_preupgrade_backup() { @@ -88,7 +101,6 @@ exit 0 _CLI_PATH="${currentCli}" return 0 } - openshell() { printf '%s\\n' "$*" >> "${openshellLog}"; return 0; } preinstall_backup_and_retire_legacy_gateway printf 'RESTORE=%s\\n' "\${NEMOCLAW_RESTORE_LATEST_BACKUP_ON_RECREATE:-}" printf 'CONFIRMED_NAMES=%s\\n' "\${_LEGACY_MANAGED_RECOVERY_NAMES_JSON:-}" @@ -97,7 +109,7 @@ exit 0 const childEnv: NodeJS.ProcessEnv = { ...process.env, HOME: home, - PATH: `${bin}:${process.env.PATH ?? ""}`, + PATH: `${bin}:${path.dirname(process.execPath)}:/usr/bin:/bin`, ...env, }; const inheritedControlKeys = [ @@ -105,7 +117,9 @@ exit 0 "NEMOCLAW_NON_INTERACTIVE", "NEMOCLAW_ACCEPT_EXPERIMENTAL_OPENSHELL_UPGRADE", "NEMOCLAW_CONFIRM_LEGACY_MANAGED_RECREATE", + "NEMOCLAW_OPENSHELL_BIN", "NEMOCLAW_OPENSHELL_UPGRADE_PREPARED", + "XDG_BIN_HOME", ].filter((key) => !(key in env)); for (const key of inheritedControlKeys) delete childEnv[key]; const result = spawnSync("bash", ["-c", snippet], { @@ -276,6 +290,42 @@ describe("install.sh OpenShell gateway upgrade guard", () => { expect(openshellLog).toBe(""); }); + it("discovers a v0.0.55 user-local OpenShell before preparing recovery (#6114)", () => { + const { result, cliLog, openshellLog } = runPreinstallUpgradeGuard( + { + NON_INTERACTIVE: "1", + NEMOCLAW_CONFIRM_LEGACY_MANAGED_RECREATE: '["alpha"]', + }, + { + hasOldCli: false, + openshellOnPath: false, + openshellVersion: "0.0.44", + userLocalOpenshell: true, + }, + ); + + expect(result.status).toBe(0); + expect(result.stdout).toContain("RESTORE=1"); + expect(result.stdout).toContain('CONFIRMED_NAMES=["alpha"]'); + expect(cliLog.split(/\r?\n/)).toContain("prepare-current"); + expect(cliLog.split(/\r?\n/)).toContain("current:backup-all"); + expect(cliLog).toContain("require-all-env=1"); + expect(openshellLog).toBe(""); + }); + + it("leaves recovery preparation untouched when OpenShell is not installed (#6114)", () => { + const { result, cliLog, openshellLog } = runPreinstallUpgradeGuard( + { NON_INTERACTIVE: "1" }, + { hasOldCli: false, openshellOnPath: false, openshellVersion: "0.0.44" }, + ); + + expect(result.status).toBe(0); + expect(result.stdout).toContain("RESTORE="); + expect(result.stdout).toContain("CONFIRMED_NAMES=[]"); + expect(cliLog).toBe(""); + expect(openshellLog).toBe(""); + }); + it("confirms a normalized legacy row whose custom-image marker is null (#6114)", () => { const { result, cliLog, openshellLog } = runPreinstallUpgradeGuard( { diff --git a/tools/e2e/openshell-gateway-upgrade-workflow-boundary.mts b/tools/e2e/openshell-gateway-upgrade-workflow-boundary.mts new file mode 100644 index 00000000000..0249310ea46 --- /dev/null +++ b/tools/e2e/openshell-gateway-upgrade-workflow-boundary.mts @@ -0,0 +1,95 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { readFileSync } from "node:fs"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; +import { isDeepStrictEqual } from "node:util"; +import YAML from "yaml"; + +const REPO_ROOT = join(dirname(fileURLToPath(import.meta.url)), "..", ".."); +const DEFAULT_WORKFLOW_PATH = join(REPO_ROOT, ".github", "workflows", "e2e.yaml"); +const JOB_NAME = "openshell-gateway-upgrade"; +const RUN_STEP_NAME = "Run OpenShell gateway upgrade live Vitest test"; +const RUN_COMMAND = + "npx tsx tools/e2e/live-vitest-invocation.mts run --test-path test/e2e/live/openshell-gateway-upgrade.test.ts"; + +type WorkflowRecord = Record; +type WorkflowStep = WorkflowRecord & { name?: string; run?: string }; + +const EXPECTED_V055_FIXTURES: WorkflowRecord[] = [ + { + id: "v0.0.55-x86_64", + runner: "ubuntu-latest", + nemoclaw_ref: "v0.0.55", + nemoclaw_commit: "95d483fe2b6569d68e59493c60f19df09a068e8f", + installer_sha256: "ff8cf448e4d17b00421545a1f333262b615b1b0aa236d0cc5aeaf4e2cae2d897", + sandbox_base_image_ref: + "ghcr.io/nvidia/nemoclaw/sandbox-base@sha256:10433a8cd2f2b809dd0fdf983514679e04c0f8aa1ff5bbff675029046033b108", + openshell_version: "0.0.44", + openclaw_version: "2026.5.22", + }, + { + id: "v0.0.55-aarch64", + runner: "ubuntu-24.04-arm", + nemoclaw_ref: "v0.0.55", + nemoclaw_commit: "95d483fe2b6569d68e59493c60f19df09a068e8f", + installer_sha256: "ff8cf448e4d17b00421545a1f333262b615b1b0aa236d0cc5aeaf4e2cae2d897", + sandbox_base_image_ref: + "ghcr.io/nvidia/nemoclaw/sandbox-base@sha256:10433a8cd2f2b809dd0fdf983514679e04c0f8aa1ff5bbff675029046033b108", + openshell_version: "0.0.44", + openclaw_version: "2026.5.22", + }, +]; + +function record(value: unknown): WorkflowRecord { + return value && typeof value === "object" && !Array.isArray(value) + ? (value as WorkflowRecord) + : {}; +} + +function jobSteps(job: WorkflowRecord): WorkflowStep[] { + return Array.isArray(job.steps) ? (job.steps as WorkflowStep[]) : []; +} + +function v055Fixtures(job: WorkflowRecord): WorkflowRecord[] { + const legacy = record(record(job.strategy).matrix).legacy; + return Array.isArray(legacy) + ? legacy.map(record).filter((fixture) => fixture.nemoclaw_ref === "v0.0.55") + : []; +} + +function requireRunContains(errors: string[], step: WorkflowStep, fragment: string): void { + if (!step.run?.includes(fragment)) { + errors.push(`${JOB_NAME} step '${RUN_STEP_NAME}' must run: ${fragment}`); + } +} + +export function readOpenShellGatewayUpgradeWorkflow( + workflowPath = DEFAULT_WORKFLOW_PATH, +): WorkflowRecord { + return YAML.parse(readFileSync(workflowPath, "utf8")) as WorkflowRecord; +} + +export function validateOpenShellGatewayUpgradeWorkflow(workflow: WorkflowRecord): string[] { + const errors: string[] = []; + const job = record(record(workflow.jobs)[JOB_NAME]); + + if (job["runs-on"] !== "${{ matrix.legacy.runner }}") { + errors.push(`${JOB_NAME} must run on \${{ matrix.legacy.runner }}`); + } + if (!isDeepStrictEqual(v055Fixtures(job), EXPECTED_V055_FIXTURES)) { + errors.push(`${JOB_NAME} v0.0.55 matrix must pin x86_64 and arm64 upgrade fixtures`); + } + + const run = jobSteps(job).find((step) => step.name === RUN_STEP_NAME) ?? {}; + requireRunContains(errors, run, RUN_COMMAND); + + return errors; +} + +export function validateOpenShellGatewayUpgradeWorkflowBoundary( + workflowPath = DEFAULT_WORKFLOW_PATH, +): string[] { + return validateOpenShellGatewayUpgradeWorkflow(readOpenShellGatewayUpgradeWorkflow(workflowPath)); +} diff --git a/tools/e2e/workflow-boundary.mts b/tools/e2e/workflow-boundary.mts index 54e05339310..92d1b6875a9 100644 --- a/tools/e2e/workflow-boundary.mts +++ b/tools/e2e/workflow-boundary.mts @@ -27,6 +27,7 @@ import { type OpenShellGatewayAuthContractWorkflow, validateOpenShellGatewayAuthContractWorkflow, } from "./openshell-gateway-auth-contract-workflow-boundary.mts"; +import { validateOpenShellGatewayUpgradeWorkflow } from "./openshell-gateway-upgrade-workflow-boundary.mts"; import { type OperationsWorkflow, validateE2eOperationsWorkflow, @@ -3739,6 +3740,7 @@ export function validateE2eWorkflow(workflowValue: unknown): string[] { workflow as unknown as OpenShellGatewayAuthContractWorkflow, ), ); + errors.push(...validateOpenShellGatewayUpgradeWorkflow(workflow)); errors.push(...validateE2eOperationsWorkflow(workflow as unknown as OperationsWorkflow)); errors.push(...validateSecurityPostureWorkflow(workflow)); const triggers = asRecord(workflow.on ?? workflow[true as unknown as string]);