From 8c397f24cb2e2d8b717f1b4dd93591e479489eca Mon Sep 17 00:00:00 2001 From: Aaron Erickson Date: Tue, 28 Jul 2026 10:54:07 -0700 Subject: [PATCH 1/7] refactor(onboard): separate compute driver from gateway launcher --- src/lib/onboard.ts | 4 +- src/lib/onboard/compute/plan.test.ts | 70 +++++++++++++++++++ src/lib/onboard/compute/plan.ts | 36 ++++++++++ src/lib/onboard/docker-driver-platform.ts | 6 +- .../onboard/sandbox-registry-metadata.test.ts | 52 +++++--------- src/lib/onboard/sandbox-registry-metadata.ts | 10 ++- 6 files changed, 134 insertions(+), 44 deletions(-) create mode 100644 src/lib/onboard/compute/plan.test.ts create mode 100644 src/lib/onboard/compute/plan.ts diff --git a/src/lib/onboard.ts b/src/lib/onboard.ts index f103f5b92f..377490456f 100644 --- a/src/lib/onboard.ts +++ b/src/lib/onboard.ts @@ -124,6 +124,7 @@ const providerKeyBridge: typeof import("./onboard/provider-key-bridge") = requir const compatibleEndpointGatewayRoute: typeof import("./onboard/inference-providers/compatible-endpoint-gateway-route") = require("./onboard/inference-providers/compatible-endpoint-gateway-route"); const { isLinuxDockerDriverGatewayEnabled, + resolveCurrentOpenShellComputePlan, }: typeof import("./onboard/docker-driver-platform") = require("./onboard/docker-driver-platform"); const { reconcileGatewayGpuReuseForGpuIntent, @@ -2205,9 +2206,10 @@ async function recoverGatewayRuntime() { return true; } +const currentOpenShellComputePlan = resolveCurrentOpenShellComputePlan(); const { getSandboxRuntimeRegistryFields, hasSandboxGpuDrift, updateReusedSandboxMetadata } = sandboxRegistryMetadata.createSandboxRegistryMetadataHelpers({ - isLinuxDockerDriverGatewayEnabled, + openShellComputeDriverName: currentOpenShellComputePlan.driverName, getInstalledOpenshellVersion, runCaptureOpenshell, }); diff --git a/src/lib/onboard/compute/plan.test.ts b/src/lib/onboard/compute/plan.test.ts new file mode 100644 index 0000000000..2a66934680 --- /dev/null +++ b/src/lib/onboard/compute/plan.test.ts @@ -0,0 +1,70 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { describe, expect, it } from "vitest"; +import { isLinuxDockerDriverGatewayEnabled } from "../docker-driver-platform"; +import { resolveCurrentOpenShellComputePlan, usesManagedDockerGateway } from "./plan"; + +describe("current OpenShell compute plan", () => { + it.each([ + { + label: "Linux x64", + platform: "linux" as const, + arch: "x64" as const, + driverName: "docker", + gatewayLauncher: "nemoclaw", + }, + { + label: "Linux arm64", + platform: "linux" as const, + arch: "arm64" as const, + driverName: "docker", + gatewayLauncher: "nemoclaw", + }, + { + label: "Apple Silicon macOS", + platform: "darwin" as const, + arch: "arm64" as const, + driverName: "docker", + gatewayLauncher: "nemoclaw", + }, + { + label: "Intel macOS", + platform: "darwin" as const, + arch: "x64" as const, + driverName: "kubernetes", + gatewayLauncher: "openshell", + }, + { + label: "Windows x64", + platform: "win32" as const, + arch: "x64" as const, + driverName: "kubernetes", + gatewayLauncher: "openshell", + }, + ])("preserves the existing driver and gateway-launch behavior on $label (#7744)", ({ + platform, + arch, + driverName, + gatewayLauncher, + }) => { + expect(resolveCurrentOpenShellComputePlan(platform, arch)).toEqual({ + driverName, + gatewayLauncher, + }); + expect(isLinuxDockerDriverGatewayEnabled(platform, arch)).toBe(driverName === "docker"); + }); + + it.each([ + { driverName: "docker", gatewayLauncher: "nemoclaw", expected: true }, + { driverName: "docker", gatewayLauncher: "openshell", expected: false }, + { driverName: "podman", gatewayLauncher: "nemoclaw", expected: false }, + { driverName: "mxc", gatewayLauncher: "nemoclaw", expected: false }, + ] as const)("reports Docker lifecycle ownership as $expected for $driverName with the $gatewayLauncher launcher (#7744)", ({ + driverName, + gatewayLauncher, + expected, + }) => { + expect(usesManagedDockerGateway({ driverName, gatewayLauncher })).toBe(expected); + }); +}); diff --git a/src/lib/onboard/compute/plan.ts b/src/lib/onboard/compute/plan.ts new file mode 100644 index 0000000000..1e6079d086 --- /dev/null +++ b/src/lib/onboard/compute/plan.ts @@ -0,0 +1,36 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +export type OpenShellGatewayLauncher = "nemoclaw" | "openshell"; + +/** + * Keeps OpenShell driver identity separate from the component that launches + * its gateway. A future driver does not inherit Docker lifecycle behavior + * because NemoClaw launches its gateway. + */ +export interface OpenShellComputePlan { + readonly driverName: string; + readonly gatewayLauncher: OpenShellGatewayLauncher; +} + +/** + * Describes the behavior NemoClaw uses today. Driver selection will move behind + * this seam without changing the existing Docker and Kubernetes paths first. + */ +export function resolveCurrentOpenShellComputePlan( + platform: NodeJS.Platform = process.platform, + arch: NodeJS.Architecture = process.arch, +): OpenShellComputePlan { + const managedDockerGateway = platform === "linux" || (platform === "darwin" && arch === "arm64"); + + return { + driverName: managedDockerGateway ? "docker" : "kubernetes", + gatewayLauncher: managedDockerGateway ? "nemoclaw" : "openshell", + }; +} + +export function usesManagedDockerGateway( + plan: Pick, +): boolean { + return plan.driverName === "docker" && plan.gatewayLauncher === "nemoclaw"; +} diff --git a/src/lib/onboard/docker-driver-platform.ts b/src/lib/onboard/docker-driver-platform.ts index 2dbc453cc4..bb0b075640 100644 --- a/src/lib/onboard/docker-driver-platform.ts +++ b/src/lib/onboard/docker-driver-platform.ts @@ -1,9 +1,13 @@ // SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 +import { resolveCurrentOpenShellComputePlan, usesManagedDockerGateway } from "./compute/plan"; + +export { resolveCurrentOpenShellComputePlan } from "./compute/plan"; + export function isLinuxDockerDriverGatewayEnabled( platform: NodeJS.Platform = process.platform, arch: NodeJS.Architecture = process.arch, ): boolean { - return platform === "linux" || (platform === "darwin" && arch === "arm64"); + return usesManagedDockerGateway(resolveCurrentOpenShellComputePlan(platform, arch)); } diff --git a/src/lib/onboard/sandbox-registry-metadata.test.ts b/src/lib/onboard/sandbox-registry-metadata.test.ts index 055deb69d8..c37f57d0c1 100644 --- a/src/lib/onboard/sandbox-registry-metadata.test.ts +++ b/src/lib/onboard/sandbox-registry-metadata.test.ts @@ -8,34 +8,16 @@ import { afterEach, describe, expect, it, vi } from "vitest"; import type { AgentDefinition } from "../agent/defs"; import type { SandboxGpuConfig } from "./sandbox-gpu-mode"; -const ORIGINAL_PLATFORM = Object.getOwnPropertyDescriptor(process, "platform"); - /** - * Overrides process.platform for runtime-driver metadata tests. + * Loads the compiled metadata helpers with an explicit resolved compute driver. */ -function setPlatform(platform: NodeJS.Platform): void { - Object.defineProperty(process, "platform", { value: platform, configurable: true }); -} - -/** - * Restores the original process.platform descriptor after each platform-specific assertion. - */ -function restorePlatform(): void { - if (ORIGINAL_PLATFORM) { - Object.defineProperty(process, "platform", ORIGINAL_PLATFORM); - } -} - -/** - * Loads the compiled metadata helpers after each test has configured process state. - */ -async function makeHelpers(opts: { dockerDriverEnabled: boolean }) { +async function makeHelpers(driverName: string) { // Import the compiled module: sandbox-registry-metadata.ts pulls in state/registry, // which transitively requires the JS-only `./platform` helper that vitest cannot // resolve from TS source. Same pattern as `vm-dns-monkeypatch.test.ts`. const metadata = await import("./sandbox-registry-metadata"); return metadata.createSandboxRegistryMetadataHelpers({ - isLinuxDockerDriverGatewayEnabled: () => opts.dockerDriverEnabled, + openShellComputeDriverName: driverName, getInstalledOpenshellVersion: () => "0.0.42", runCaptureOpenshell: () => null, }); @@ -106,7 +88,7 @@ describe("sandbox registry metadata", () => { }); const helpers = metadata.createSandboxRegistryMetadataHelpers({ - isLinuxDockerDriverGatewayEnabled: () => true, + openShellComputeDriverName: "docker", getInstalledOpenshellVersion: () => "0.0.44", runCaptureOpenshell: () => "openshell 0.0.44", }); @@ -155,7 +137,7 @@ describe("sandbox registry metadata", () => { const dashboardPorts = await import("./dashboard-port"); const gatewayRegistry = await import("../state/gateway-registry"); const helpers = metadata.createSandboxRegistryMetadataHelpers({ - isLinuxDockerDriverGatewayEnabled: () => true, + openShellComputeDriverName: "docker", getInstalledOpenshellVersion: () => "0.0.44", runCaptureOpenshell: () => "openshell 0.0.44", }); @@ -190,32 +172,30 @@ describe("sandbox registry metadata", () => { }); describe("getSandboxRuntimeRegistryFields openshellDriver", () => { - afterEach(restorePlatform); - - it("records Docker for macOS sandboxes on the Docker-driver gateway path", async () => { - setPlatform("darwin"); - const helpers = await makeHelpers({ dockerDriverEnabled: true }); + it("records the resolved Docker compute driver (#7744)", async () => { + const helpers = await makeHelpers("docker"); const fields = helpers.getSandboxRuntimeRegistryFields(GPU_OFF); expect(fields.openshellDriver).toBe("docker"); }); - it("records Docker for Linux sandboxes on the Docker-driver gateway path", async () => { - setPlatform("linux"); - const helpers = await makeHelpers({ dockerDriverEnabled: true }); + it("records the resolved Kubernetes compute driver (#7744)", async () => { + const helpers = await makeHelpers("kubernetes"); const fields = helpers.getSandboxRuntimeRegistryFields(GPU_OFF); - expect(fields.openshellDriver).toBe("docker"); + expect(fields.openshellDriver).toBe("kubernetes"); }); - it("records Kubernetes for legacy Linux sandboxes when the Docker-driver gateway is disabled", async () => { - setPlatform("linux"); - const helpers = await makeHelpers({ dockerDriverEnabled: false }); + it.each([ + "podman", + "mxc", + ])("passes the resolved %s driver through to registry metadata (#7744)", async (driverName) => { + const helpers = await makeHelpers(driverName); const fields = helpers.getSandboxRuntimeRegistryFields(GPU_OFF); - expect(fields.openshellDriver).toBe("kubernetes"); + expect(fields.openshellDriver).toBe(driverName); }); }); diff --git a/src/lib/onboard/sandbox-registry-metadata.ts b/src/lib/onboard/sandbox-registry-metadata.ts index 9abb49a5ec..5df00c3b44 100644 --- a/src/lib/onboard/sandbox-registry-metadata.ts +++ b/src/lib/onboard/sandbox-registry-metadata.ts @@ -8,7 +8,7 @@ import { getSandboxAgentRegistryFields } from "./sandbox-agent"; import type { SandboxGpuConfig } from "./sandbox-gpu-mode"; export interface SandboxRegistryMetadataDeps { - isLinuxDockerDriverGatewayEnabled(): boolean; + openShellComputeDriverName: string; getInstalledOpenshellVersion(versionOutput?: string | null): string | null; runCaptureOpenshell(args: string[], opts?: Record): string | null; } @@ -55,10 +55,6 @@ export function createSandboxRegistryMetadataHelpers( | "openshellDriver" | "openshellVersion" > { - // OpenShell's Docker-driver gateway always starts with OPENSHELL_DRIVERS=docker, - // including on macOS arm64 (#3454). Recording "vm" for darwin here makes later - // setup misclassify the sandbox and run VM-only DNS monkeypatch / warning paths - // (#3728). return { gpuEnabled: config.sandboxGpuEnabled, hostGpuDetected: config.hostGpuDetected, @@ -68,7 +64,9 @@ export function createSandboxRegistryMetadataHelpers( // Only persist a proof when this run produced one; omit on reuse/update // paths so a prior proof result is preserved rather than nulled out. ...(config.sandboxGpuProof ? { sandboxGpuProof: config.sandboxGpuProof } : {}), - openshellDriver: deps.isLinuxDockerDriverGatewayEnabled() ? "docker" : "kubernetes", + // Driver identity comes from the resolved compute plan, not the host + // gateway launcher; those layers may differ (#7744). + openshellDriver: deps.openShellComputeDriverName, openshellVersion: deps.getInstalledOpenshellVersion( deps.runCaptureOpenshell(["--version"], { ignoreError: true }), ), From cdcd37bc881bb03764b1a7c7d3b9c155a67ac31b Mon Sep 17 00:00:00 2001 From: Aaron Erickson Date: Tue, 28 Jul 2026 11:39:31 -0700 Subject: [PATCH 2/7] fix(onboard): resolve compute driver when recording metadata --- src/lib/onboard.ts | 10 ++++------ .../onboard/sandbox-registry-metadata.test.ts | 20 ++++++++++++++++--- src/lib/onboard/sandbox-registry-metadata.ts | 4 ++-- 3 files changed, 23 insertions(+), 11 deletions(-) diff --git a/src/lib/onboard.ts b/src/lib/onboard.ts index 377490456f..cada984002 100644 --- a/src/lib/onboard.ts +++ b/src/lib/onboard.ts @@ -122,10 +122,8 @@ const { }: typeof import("./onboard/created-sandbox-finalization") = require("./onboard/created-sandbox-finalization"); const providerKeyBridge: typeof import("./onboard/provider-key-bridge") = require("./onboard/provider-key-bridge"); const compatibleEndpointGatewayRoute: typeof import("./onboard/inference-providers/compatible-endpoint-gateway-route") = require("./onboard/inference-providers/compatible-endpoint-gateway-route"); -const { - isLinuxDockerDriverGatewayEnabled, - resolveCurrentOpenShellComputePlan, -}: typeof import("./onboard/docker-driver-platform") = require("./onboard/docker-driver-platform"); +const dockerDriverPlatform: typeof import("./onboard/docker-driver-platform") = require("./onboard/docker-driver-platform"); +const { isLinuxDockerDriverGatewayEnabled } = dockerDriverPlatform; const { reconcileGatewayGpuReuseForGpuIntent, }: typeof import("./onboard/gateway-gpu-passthrough") = require("./onboard/gateway-gpu-passthrough"); @@ -2206,10 +2204,10 @@ async function recoverGatewayRuntime() { return true; } -const currentOpenShellComputePlan = resolveCurrentOpenShellComputePlan(); const { getSandboxRuntimeRegistryFields, hasSandboxGpuDrift, updateReusedSandboxMetadata } = sandboxRegistryMetadata.createSandboxRegistryMetadataHelpers({ - openShellComputeDriverName: currentOpenShellComputePlan.driverName, + getOpenShellComputeDriverName: () => + dockerDriverPlatform.resolveCurrentOpenShellComputePlan().driverName, getInstalledOpenshellVersion, runCaptureOpenshell, }); diff --git a/src/lib/onboard/sandbox-registry-metadata.test.ts b/src/lib/onboard/sandbox-registry-metadata.test.ts index c37f57d0c1..d6798a13fd 100644 --- a/src/lib/onboard/sandbox-registry-metadata.test.ts +++ b/src/lib/onboard/sandbox-registry-metadata.test.ts @@ -17,7 +17,7 @@ async function makeHelpers(driverName: string) { // resolve from TS source. Same pattern as `vm-dns-monkeypatch.test.ts`. const metadata = await import("./sandbox-registry-metadata"); return metadata.createSandboxRegistryMetadataHelpers({ - openShellComputeDriverName: driverName, + getOpenShellComputeDriverName: () => driverName, getInstalledOpenshellVersion: () => "0.0.42", runCaptureOpenshell: () => null, }); @@ -88,7 +88,7 @@ describe("sandbox registry metadata", () => { }); const helpers = metadata.createSandboxRegistryMetadataHelpers({ - openShellComputeDriverName: "docker", + getOpenShellComputeDriverName: () => "docker", getInstalledOpenshellVersion: () => "0.0.44", runCaptureOpenshell: () => "openshell 0.0.44", }); @@ -137,7 +137,7 @@ describe("sandbox registry metadata", () => { const dashboardPorts = await import("./dashboard-port"); const gatewayRegistry = await import("../state/gateway-registry"); const helpers = metadata.createSandboxRegistryMetadataHelpers({ - openShellComputeDriverName: "docker", + getOpenShellComputeDriverName: () => "docker", getInstalledOpenshellVersion: () => "0.0.44", runCaptureOpenshell: () => "openshell 0.0.44", }); @@ -198,4 +198,18 @@ describe("getSandboxRuntimeRegistryFields openshellDriver", () => { expect(fields.openshellDriver).toBe(driverName); }); + + it("resolves driver identity when metadata is recorded rather than at module load (#7744)", async () => { + const metadata = await import("./sandbox-registry-metadata"); + let driverName = "docker"; + const helpers = metadata.createSandboxRegistryMetadataHelpers({ + getOpenShellComputeDriverName: () => driverName, + getInstalledOpenshellVersion: () => "0.0.42", + runCaptureOpenshell: () => null, + }); + + driverName = "kubernetes"; + + expect(helpers.getSandboxRuntimeRegistryFields(GPU_OFF).openshellDriver).toBe("kubernetes"); + }); }); diff --git a/src/lib/onboard/sandbox-registry-metadata.ts b/src/lib/onboard/sandbox-registry-metadata.ts index 5df00c3b44..57b82000d8 100644 --- a/src/lib/onboard/sandbox-registry-metadata.ts +++ b/src/lib/onboard/sandbox-registry-metadata.ts @@ -8,7 +8,7 @@ import { getSandboxAgentRegistryFields } from "./sandbox-agent"; import type { SandboxGpuConfig } from "./sandbox-gpu-mode"; export interface SandboxRegistryMetadataDeps { - openShellComputeDriverName: string; + getOpenShellComputeDriverName(): string; getInstalledOpenshellVersion(versionOutput?: string | null): string | null; runCaptureOpenshell(args: string[], opts?: Record): string | null; } @@ -66,7 +66,7 @@ export function createSandboxRegistryMetadataHelpers( ...(config.sandboxGpuProof ? { sandboxGpuProof: config.sandboxGpuProof } : {}), // Driver identity comes from the resolved compute plan, not the host // gateway launcher; those layers may differ (#7744). - openshellDriver: deps.openShellComputeDriverName, + openshellDriver: deps.getOpenShellComputeDriverName(), openshellVersion: deps.getInstalledOpenshellVersion( deps.runCaptureOpenshell(["--version"], { ignoreError: true }), ), From 3eccbec3e4d7399c11ebae914b6308a08ca830c5 Mon Sep 17 00:00:00 2001 From: Aaron Erickson Date: Tue, 28 Jul 2026 11:49:17 -0700 Subject: [PATCH 3/7] test(e2e): budget planner-report integration case --- test/e2e/support/e2e-report-to-pr-workflow-boundary.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/e2e/support/e2e-report-to-pr-workflow-boundary.test.ts b/test/e2e/support/e2e-report-to-pr-workflow-boundary.test.ts index a52440b09e..69360e6cb9 100644 --- a/test/e2e/support/e2e-report-to-pr-workflow-boundary.test.ts +++ b/test/e2e/support/e2e-report-to-pr-workflow-boundary.test.ts @@ -904,7 +904,7 @@ it("carries the generated planner matrix through the workflow output and PR repo } finally { fs.rmSync(directory, { force: true, recursive: true }); } -}); +}, 30_000); it("builds controller target matrices only from trusted runner mappings (#7031)", () => { const target = "ubuntu-repo-cloud-langchain-deepagents-code"; From 2f03907c3a7ec151d7f5d4bb2a73abafc2849f83 Mon Sep 17 00:00:00 2001 From: Aaron Erickson Date: Tue, 28 Jul 2026 12:07:27 -0700 Subject: [PATCH 4/7] ci(images): publish complete managed OCI images --- .github/workflows/base-image.yaml | 162 +++++++- .github/workflows/managed-images.yaml | 370 ++++++++++++++++++ .../support/base-image-publication.test.ts | 116 +++++- ...managed-image-publication-workflow.test.ts | 332 ++++++++++++++++ tools/e2e/base-image-publication.mts | 92 ++++- 5 files changed, 1050 insertions(+), 22 deletions(-) create mode 100644 .github/workflows/managed-images.yaml create mode 100644 test/managed-image-publication-workflow.test.ts diff --git a/.github/workflows/base-image.yaml b/.github/workflows/base-image.yaml index 0431ae9901..9036fa0110 100644 --- a/.github/workflows/base-image.yaml +++ b/.github/workflows/base-image.yaml @@ -1,15 +1,14 @@ # SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. # SPDX-License-Identifier: Apache-2.0 -# Build and push the sandbox base images to GHCR. +# Build and push sandbox base images, then publish validated managed images. # # Triggers: -# - Push to main when a base-image workflow input changes +# - Push to main when a base- or managed-image input changes # - Manual dispatch for ad-hoc rebuilds # -# The base image contains the expensive, rarely-changing layers (apt, gosu, -# user setup, openclaw CLI). The production Dockerfile layers PR-specific -# code on top via: FROM ghcr.io/nvidia/nemoclaw/sandbox-base: +# Base images contain the expensive, rarely-changing layers. Complete image +# publication consumes exact base digests from the same trusted workflow run. name: Images / Base Images @@ -22,6 +21,20 @@ on: # Re-run when this workflow gains or changes a publisher so the new path # takes effect immediately after merge instead of waiting for another tag. - ".github/workflows/base-image.yaml" + - ".github/workflows/managed-images.yaml" + - ".dockerignore" + # Complete managed-image inputs. Keep these reviewed families synchronized + # with tools/e2e/base-image-publication.mts. + - "Dockerfile" + - "agents/**" + - "nemoclaw/**" + - "nemoclaw-blueprint/**" + - "scripts/**" + - "src/lib/actions/sandbox/openshell-child-visible-credentials.v*.json" + - "src/lib/messaging/**" + - "src/lib/tool-disclosure.ts" + - "tools/mcp-tool-discovery-runtime/**" + - "tsconfig.runtime-preloads.json" - "Dockerfile.base" - "agents/openclaw/openclaw-runtime/package.json" - "agents/openclaw/openclaw-runtime/package-lock.json" @@ -57,8 +70,8 @@ permissions: packages: write concurrency: - group: base-image - cancel-in-progress: true + group: base-image-${{ github.ref }} + cancel-in-progress: ${{ !startsWith(github.ref, 'refs/tags/v') }} env: REGISTRY: ghcr.io @@ -256,6 +269,7 @@ jobs: printf 'openclaw_build_arg=%s\n' "$openclaw_build_arg" >> "$GITHUB_OUTPUT" - name: Build and push + id: build uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0 with: context: . @@ -268,6 +282,59 @@ jobs: cache-to: type=registry,ref=${{ env.REGISTRY }}/${{ matrix.image }}:buildcache,mode=max build-args: ${{ steps.production-build-args.outputs.openclaw_build_arg }} + - name: Export managed base image contract + env: + AGENT: ${{ matrix.agent }} + DIGEST: ${{ steps.build.outputs.digest }} + IMAGE: ${{ env.REGISTRY }}/${{ matrix.image }} + run: | + set -euo pipefail + if [[ ! "$DIGEST" =~ ^sha256:[0-9a-f]{64}$ ]]; then + echo "ERROR: base image build did not return a valid digest: $DIGEST" >&2 + exit 1 + fi + reference="${IMAGE}@${DIGEST}" + docker buildx imagetools inspect "$reference" >/dev/null + + contract_dir="$RUNNER_TEMP/managed-base-contract" + mkdir -p "$contract_dir" + jq -n \ + --arg agent "$AGENT" \ + --arg digest "$DIGEST" \ + --arg image "$IMAGE" \ + --arg reference "$reference" \ + --arg revision "$GITHUB_SHA" \ + --argjson runAttempt "$GITHUB_RUN_ATTEMPT" \ + --argjson runId "$GITHUB_RUN_ID" \ + '{ + contractVersion: 1, + agent: $agent, + image: $image, + digest: $digest, + reference: $reference, + platforms: ["linux/amd64", "linux/arm64"], + sourceRevision: $revision, + run: { + id: $runId, + attempt: $runAttempt + } + }' > "$contract_dir/contract.json" + jq -e \ + '.contractVersion == 1 + and (.sourceRevision | test("^[0-9a-f]{40}$")) + and (.digest | test("^sha256:[0-9a-f]{64}$")) + and .reference == (.image + "@" + .digest) + and .platforms == ["linux/amd64", "linux/arm64"]' \ + "$contract_dir/contract.json" >/dev/null + + - name: Upload managed base image contract + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: managed-base-${{ matrix.agent }} + path: ${{ runner.temp }}/managed-base-contract/contract.json + if-no-files-found: error + retention-days: 1 + # Preserve the established required-check name while making tag publication # contingent on both native platform builds succeeding. build-and-push-openclaw: @@ -307,6 +374,7 @@ jobs: type=sha,prefix=,format=short - name: Create and verify multi-platform manifest + id: manifest env: IMAGE: ${{ env.REGISTRY }}/nvidia/nemoclaw/sandbox-base TAGS: ${{ steps.meta.outputs.tags }} @@ -353,3 +421,83 @@ jobs: echo "ERROR: published manifest has unexpected platforms: $actual_platforms" >&2 exit 1 fi + manifest_inspect="$(docker buildx imagetools inspect "$first_tag")" + mapfile -t manifest_digests < <( + printf '%s\n' "$manifest_inspect" \ + | sed -nE 's/^Digest:[[:space:]]*(sha256:[0-9a-f]{64})$/\1/p' + ) + if [ "${#manifest_digests[@]}" -ne 1 ]; then + echo "ERROR: expected one published OpenClaw base digest." >&2 + exit 1 + fi + digest="${manifest_digests[0]}" + docker buildx imagetools inspect "$IMAGE@$digest" >/dev/null + printf 'digest=%s\n' "$digest" >> "$GITHUB_OUTPUT" + + - name: Export managed base image contract + env: + DIGEST: ${{ steps.manifest.outputs.digest }} + IMAGE: ${{ env.REGISTRY }}/nvidia/nemoclaw/sandbox-base + run: | + set -euo pipefail + if [[ ! "$DIGEST" =~ ^sha256:[0-9a-f]{64}$ ]]; then + echo "ERROR: OpenClaw base manifest did not return a valid digest: $DIGEST" >&2 + exit 1 + fi + reference="${IMAGE}@${DIGEST}" + docker buildx imagetools inspect "$reference" >/dev/null + + contract_dir="$RUNNER_TEMP/managed-base-contract" + mkdir -p "$contract_dir" + jq -n \ + --arg digest "$DIGEST" \ + --arg image "$IMAGE" \ + --arg reference "$reference" \ + --arg revision "$GITHUB_SHA" \ + --argjson runAttempt "$GITHUB_RUN_ATTEMPT" \ + --argjson runId "$GITHUB_RUN_ID" \ + '{ + contractVersion: 1, + agent: "openclaw", + image: $image, + digest: $digest, + reference: $reference, + platforms: ["linux/amd64", "linux/arm64"], + sourceRevision: $revision, + run: { + id: $runId, + attempt: $runAttempt + } + }' > "$contract_dir/contract.json" + jq -e \ + '.contractVersion == 1 + and .agent == "openclaw" + and (.sourceRevision | test("^[0-9a-f]{40}$")) + and (.digest | test("^sha256:[0-9a-f]{64}$")) + and .reference == (.image + "@" + .digest) + and .platforms == ["linux/amd64", "linux/arm64"]' \ + "$contract_dir/contract.json" >/dev/null + + - name: Upload managed base image contract + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: managed-base-openclaw + path: ${{ runner.temp }}/managed-base-contract/contract.json + if-no-files-found: error + retention-days: 1 + + # The managed-image publisher consumes exact base digest contracts from the + # jobs above. Keep it in the same run so a release cannot race its bases. + publish-managed-images: + name: Publish complete managed images + if: >- + github.repository == 'NVIDIA/NemoClaw' && + (github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/tags/v')) && + (github.event_name != 'workflow_dispatch' || inputs.openclaw_version == '') + needs: + - build-and-push + - build-and-push-openclaw + permissions: + contents: read + packages: write + uses: ./.github/workflows/managed-images.yaml diff --git a/.github/workflows/managed-images.yaml b/.github/workflows/managed-images.yaml new file mode 100644 index 0000000000..ac0cbcba2c --- /dev/null +++ b/.github/workflows/managed-images.yaml @@ -0,0 +1,370 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +# Publish complete managed images from exact base-image contracts. A later +# onboarding slice will apply runtime profiles and consume their public release +# aliases or exact digests. The initial platform is Linux x86_64; the OCI image +# contract remains neutral across Podman, Docker, and future compute drivers. + +name: Images / Managed Images + +on: + workflow_call: + +permissions: + contents: read + packages: write + +env: + REGISTRY: ghcr.io + +jobs: + build-validate-and-promote: + name: Build, validate, and promote ${{ matrix.display_name }} managed image + runs-on: ubuntu-24.04 + timeout-minutes: 90 + strategy: + fail-fast: false + matrix: + include: + - agent: openclaw + display_name: OpenClaw + dockerfile: Dockerfile + base_image: nvidia/nemoclaw/sandbox-base + image: nvidia/nemoclaw/openclaw-sandbox + platform: linux/amd64 + artifact_platform: linux-amd64 + required_binary: /usr/local/bin/openclaw + - agent: hermes + display_name: Hermes + dockerfile: agents/hermes/Dockerfile + base_image: nvidia/nemoclaw/hermes-sandbox-base + image: nvidia/nemoclaw/hermes-sandbox + platform: linux/amd64 + artifact_platform: linux-amd64 + required_binary: /usr/local/bin/hermes + - agent: langchain-deepagents-code + display_name: Deep Agents Code + dockerfile: agents/langchain-deepagents-code/Dockerfile + base_image: nvidia/nemoclaw/langchain-deepagents-code-sandbox-base + image: nvidia/nemoclaw/langchain-deepagents-code-sandbox + platform: linux/amd64 + artifact_platform: linux-amd64 + required_binary: /usr/local/bin/dcode + steps: + - name: Checkout + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + + - name: Download exact base image contract + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: managed-base-${{ matrix.agent }} + path: ${{ runner.temp }}/managed-base-contract + + - name: Validate exact base image contract + id: base + shell: bash + env: + AGENT: ${{ matrix.agent }} + BASE_IMAGE: ${{ env.REGISTRY }}/${{ matrix.base_image }} + CONTRACT: ${{ runner.temp }}/managed-base-contract/contract.json + PLATFORM: ${{ matrix.platform }} + run: | + set -euo pipefail + if [ ! -f "$CONTRACT" ] || [ -L "$CONTRACT" ]; then + echo "ERROR: exact base image contract is missing or is a symlink." >&2 + exit 1 + fi + if ! jq -e \ + --arg agent "$AGENT" \ + --arg image "$BASE_IMAGE" \ + --arg platform "$PLATFORM" \ + --arg revision "$GITHUB_SHA" \ + --argjson runAttempt "$GITHUB_RUN_ATTEMPT" \ + --argjson runId "$GITHUB_RUN_ID" \ + ' + (keys | sort) == [ + "agent", + "contractVersion", + "digest", + "image", + "platforms", + "reference", + "run", + "sourceRevision" + ] + and .contractVersion == 1 + and .agent == $agent + and .image == $image + and (.digest | test("^sha256:[0-9a-f]{64}$")) + and .reference == (.image + "@" + .digest) + and (.platforms | index($platform)) != null + and .sourceRevision == $revision + and .run == {id: $runId, attempt: $runAttempt} + ' "$CONTRACT" >/dev/null + then + echo "ERROR: exact base image contract failed closed validation." >&2 + exit 1 + fi + printf 'ref=%s\n' "$(jq -r '.reference' "$CONTRACT")" >> "$GITHUB_OUTPUT" + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4.2.0 + + - name: Log in to GHCR + uses: docker/login-action@af1e73f918a031802d376d3c8bbc3fe56130a9b0 # v4.4.0 + with: + registry: ${{ env.REGISTRY }} + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Validate production build args + env: + BASE_IMAGE: ${{ steps.base.outputs.ref }} + DOCKERFILE: ${{ matrix.dockerfile }} + run: | + set -euo pipefail + build_args=(-f "$DOCKERFILE" --build-arg "BASE_IMAGE=${BASE_IMAGE}") + scripts/check-production-build-args.sh "${build_args[@]}" + + # Push only the canonical digest. Consumer aliases do not exist until the + # exact image has passed anonymous-pull and runtime-contract validation. + - name: Build and push managed image by digest + id: build + uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0 + with: + context: . + file: ${{ matrix.dockerfile }} + platforms: ${{ matrix.platform }} + outputs: type=image,name=${{ env.REGISTRY }}/${{ matrix.image }},push-by-digest=true,name-canonical=true,push=true + labels: | + org.opencontainers.image.source=https://github.com/${{ github.repository }} + org.opencontainers.image.revision=${{ github.sha }} + io.nvidia.nemoclaw.agent=${{ matrix.agent }} + io.nvidia.nemoclaw.managed-image.contract=1 + io.nvidia.nemoclaw.managed-image.platform=${{ matrix.platform }} + build-args: BASE_IMAGE=${{ steps.base.outputs.ref }} + cache-from: type=registry,ref=${{ env.REGISTRY }}/${{ matrix.image }}:buildcache-${{ matrix.artifact_platform }} + cache-to: type=registry,ref=${{ env.REGISTRY }}/${{ matrix.image }}:buildcache-${{ matrix.artifact_platform }},mode=max + provenance: mode=max + sbom: true + + - name: Validate exact managed image before promotion + id: validate + shell: bash + env: + AGENT: ${{ matrix.agent }} + DIGEST: ${{ steps.build.outputs.digest }} + IMAGE: ${{ env.REGISTRY }}/${{ matrix.image }} + PLATFORM: ${{ matrix.platform }} + REQUIRED_BINARY: ${{ matrix.required_binary }} + run: | + set -euo pipefail + if [[ ! "$DIGEST" =~ ^sha256:[0-9a-f]{64}$ ]]; then + echo "ERROR: managed image build did not return a valid digest: $DIGEST" >&2 + exit 1 + fi + reference="${IMAGE}@${DIGEST}" + docker buildx imagetools inspect "$reference" >/dev/null + + anonymous_config="$(mktemp -d "$RUNNER_TEMP/anonymous-docker-XXXXXX")" + chmod 0700 "$anonymous_config" + if ! DOCKER_CONFIG="$anonymous_config" docker pull --platform "$PLATFORM" "$reference"; then + echo "::error::Anonymous exact-digest pull failed for ${reference}. Before aliases can be promoted, bootstrap the GHCR package ${IMAGE} with public visibility, then rerun this workflow." + exit 1 + fi + + entrypoint="$(docker image inspect --format '{{json .Config.Entrypoint}}' "$reference")" + command="$(docker image inspect --format '{{json .Config.Cmd}}' "$reference")" + if [ "$entrypoint" != '["/usr/local/bin/nemoclaw-start"]' ]; then + echo "ERROR: managed image has unexpected entrypoint: $entrypoint" >&2 + exit 1 + fi + if [ "$command" != '["/bin/bash"]' ]; then + echo "ERROR: managed image has unexpected command: $command" >&2 + exit 1 + fi + + agent_label="$( + docker image inspect \ + --format '{{index .Config.Labels "io.nvidia.nemoclaw.agent"}}' \ + "$reference" + )" + contract_label="$( + docker image inspect \ + --format '{{index .Config.Labels "io.nvidia.nemoclaw.managed-image.contract"}}' \ + "$reference" + )" + platform_label="$( + docker image inspect \ + --format '{{index .Config.Labels "io.nvidia.nemoclaw.managed-image.platform"}}' \ + "$reference" + )" + revision_label="$( + docker image inspect \ + --format '{{index .Config.Labels "org.opencontainers.image.revision"}}' \ + "$reference" + )" + if [ "$agent_label" != "$AGENT" ] || + [ "$contract_label" != "1" ] || + [ "$platform_label" != "$PLATFORM" ] || + [ "$revision_label" != "$GITHUB_SHA" ]; then + echo "ERROR: managed image contract labels do not match the build identity." >&2 + exit 1 + fi + + DOCKER_CONFIG="$anonymous_config" docker run --rm -i \ + --network none \ + --env "REQUIRED_BINARY=$REQUIRED_BINARY" \ + --entrypoint /bin/sh \ + "$reference" -eu -s <<'VALIDATE_FILESYSTEM' + test -x /usr/local/bin/nemoclaw-start + test -x "$REQUIRED_BINARY" + test -r /opt/nemoclaw-blueprint/blueprint.yaml + test -r /usr/local/share/nemoclaw/node-tar-inventory.json + test ! -e /usr/local/share/nemoclaw/corporate-ca.pem + test ! -L /usr/local/share/nemoclaw/corporate-ca.pem + node <<'VALIDATE_NODE_TAR' + const fs = require("node:fs"); + const scan = JSON.parse( + fs.readFileSync("/usr/local/share/nemoclaw/node-tar-inventory.json", "utf8"), + ); + if ( + scan.schema !== 1 || + !Number.isInteger(scan.packageCount) || + scan.packageCount < 1 || + !Array.isArray(scan.packages) || + scan.packages.length !== scan.packageCount || + scan.packages.some((entry) => entry.status !== "fixed") + ) { + throw new Error("completed node-tar scan is missing or unsafe"); + } + VALIDATE_NODE_TAR + VALIDATE_FILESYSTEM + + version_output="$( + DOCKER_CONFIG="$anonymous_config" docker run --rm \ + --network none \ + --entrypoint "$REQUIRED_BINARY" \ + "$reference" --version 2>&1 + )" + if [ -z "$version_output" ]; then + echo "ERROR: required agent binary returned an empty version." >&2 + exit 1 + fi + printf 'reference=%s\n' "$reference" >> "$GITHUB_OUTPUT" + + - name: Promote validated managed image aliases + id: promote + shell: bash + env: + IMAGE: ${{ env.REGISTRY }}/${{ matrix.image }} + REFERENCE: ${{ steps.validate.outputs.reference }} + run: | + set -euo pipefail + if [[ ! "$GITHUB_SHA" =~ ^[0-9a-f]{40}$ ]]; then + echo "ERROR: source revision must be a full 40-character SHA." >&2 + exit 1 + fi + aliases=("${IMAGE}:${GITHUB_SHA}") + if [[ "$GITHUB_REF" == refs/tags/* ]]; then + release_tag="${GITHUB_REF#refs/tags/}" + if [[ ! "$release_tag" =~ ^v[0-9]+([.][0-9]+){1,3}([-.][0-9A-Za-z][0-9A-Za-z.-]*)?$ ]]; then + echo "ERROR: release tag is not a supported version alias: $release_tag" >&2 + exit 1 + fi + aliases+=("${IMAGE}:${release_tag}") + elif [ "$GITHUB_REF" != "refs/heads/main" ]; then + echo "ERROR: managed images may only be promoted from main or a v* release tag." >&2 + exit 1 + fi + + tag_args=() + for alias in "${aliases[@]}"; do + tag_args+=(--tag "$alias") + done + docker buildx imagetools create "${tag_args[@]}" "$REFERENCE" + + exact_raw="$RUNNER_TEMP/managed-image-exact.raw" + docker buildx imagetools inspect "$REFERENCE" --raw > "$exact_raw" + for alias in "${aliases[@]}"; do + alias_raw="$RUNNER_TEMP/managed-image-alias-${alias##*:}.raw" + docker buildx imagetools inspect "$alias" --raw > "$alias_raw" + if ! cmp -s "$exact_raw" "$alias_raw"; then + echo "ERROR: promoted alias does not resolve to the validated raw manifest: $alias" >&2 + exit 1 + fi + done + + aliases_file="$RUNNER_TEMP/managed-image-aliases.json" + printf '%s\n' "${aliases[@]}" | jq -R . | jq -s . > "$aliases_file" + printf 'aliases_file=%s\n' "$aliases_file" >> "$GITHUB_OUTPUT" + + - name: Export managed image contract + env: + AGENT: ${{ matrix.agent }} + ALIASES_FILE: ${{ steps.promote.outputs.aliases_file }} + BASE_REFERENCE: ${{ steps.base.outputs.ref }} + DIGEST: ${{ steps.build.outputs.digest }} + IMAGE: ${{ env.REGISTRY }}/${{ matrix.image }} + PLATFORM: ${{ matrix.platform }} + REFERENCE: ${{ steps.validate.outputs.reference }} + run: | + set -euo pipefail + contract_dir="$RUNNER_TEMP/managed-image-contract" + mkdir -p "$contract_dir" + jq -n \ + --arg agent "$AGENT" \ + --arg baseReference "$BASE_REFERENCE" \ + --arg digest "$DIGEST" \ + --arg image "$IMAGE" \ + --arg platform "$PLATFORM" \ + --arg reference "$REFERENCE" \ + --arg repository "$GITHUB_REPOSITORY" \ + --arg revision "$GITHUB_SHA" \ + --argjson aliases "$(cat "$ALIASES_FILE")" \ + --argjson runAttempt "$GITHUB_RUN_ATTEMPT" \ + --argjson runId "$GITHUB_RUN_ID" \ + '{ + contractVersion: 1, + agent: $agent, + image: $image, + digest: $digest, + reference: $reference, + baseReference: $baseReference, + platform: $platform, + source: { + repository: $repository, + revision: $revision + }, + run: { + id: $runId, + attempt: $runAttempt + }, + aliases: $aliases + }' > "$contract_dir/contract.json" + jq -e \ + --arg revision "$GITHUB_SHA" \ + --argjson runAttempt "$GITHUB_RUN_ATTEMPT" \ + --argjson runId "$GITHUB_RUN_ID" \ + '.contractVersion == 1 + and (.digest | test("^sha256:[0-9a-f]{64}$")) + and .reference == (.image + "@" + .digest) + and (.baseReference | test("@sha256:[0-9a-f]{64}$")) + and .source.revision == $revision + and .run == {id: $runId, attempt: $runAttempt} + and ( + (.image + ":" + $revision) as $revisionAlias + | (.aliases | index($revisionAlias)) != null + )' \ + "$contract_dir/contract.json" >/dev/null + + - name: Upload managed image contract + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: managed-image-${{ matrix.agent }}-${{ matrix.artifact_platform }} + path: ${{ runner.temp }}/managed-image-contract/contract.json + if-no-files-found: error + retention-days: 90 diff --git a/test/e2e/support/base-image-publication.test.ts b/test/e2e/support/base-image-publication.test.ts index 7bd925d003..eb82b9cc9d 100644 --- a/test/e2e/support/base-image-publication.test.ts +++ b/test/e2e/support/base-image-publication.test.ts @@ -10,6 +10,7 @@ import { describe, expect, it } from "vitest"; import { collectPaginated, + expandBaseImagePushPaths, type FirstParentHistory, githubRequest, type PublicationRun, @@ -144,8 +145,14 @@ function publisherJob( function successfulJobs(overrides: { runAttempt?: number } = {}): Record[] { const runAttempt = overrides.runAttempt ?? 1; return [ - publisherJob("Build and push OpenClaw base image", { id: 1, run_attempt: runAttempt }), - publisherJob("Build and push Hermes base image", { id: 2, run_attempt: runAttempt }), + publisherJob("Build and push OpenClaw base image", { + id: 1, + run_attempt: runAttempt, + }), + publisherJob("Build and push Hermes base image", { + id: 2, + run_attempt: runAttempt, + }), publisherJob("Build and push Deep Agents Code base image", { id: 3, run_attempt: runAttempt, @@ -154,7 +161,7 @@ function successfulJobs(overrides: { runAttempt?: number } = {}): Record { - it("extracts the checked-in literal publisher paths without runtime dependencies (#7372)", () => { + it("extracts literal paths and the reviewed managed-image input families (#7372)", () => { const source = fs.readFileSync( path.resolve(import.meta.dirname, "../../../.github/workflows/base-image.yaml"), "utf8", @@ -163,9 +170,19 @@ describe("base-image publication evidence", () => { expect(parseBaseImagePushPaths(source)).toEqual( expect.arrayContaining([ ".github/workflows/base-image.yaml", + "Dockerfile", "Dockerfile.base", + "agents/**", "agents/hermes/Dockerfile.base", "agents/langchain-deepagents-code/Dockerfile.base", + "nemoclaw/**", + "nemoclaw-blueprint/**", + "scripts/**", + "src/lib/actions/sandbox/openshell-child-visible-credentials.v*.json", + "src/lib/messaging/**", + "src/lib/tool-disclosure.ts", + "tools/mcp-tool-discovery-runtime/**", + "tsconfig.runtime-preloads.json", ]), ); }); @@ -216,6 +233,70 @@ describe("base-image publication evidence", () => { expect(() => parseBaseImagePushPaths(source)).toThrow(expected); }); + it("expands only reviewed glob families against first-parent Git history (#7744)", () => { + const calls: string[][] = []; + const expanded = expandBaseImagePushPaths( + EXPECTED_SHA, + ["Dockerfile", "agents/**", "src/lib/messaging/**"], + (args) => { + calls.push(args); + const pathspec = required(args.at(-1), "glob expansion call is missing a pathspec"); + return required( + new Map([ + [ + ":(glob)agents/**", + "agents/hermes/Dockerfile\nagents/openclaw/manifest.yaml\nagents/hermes/Dockerfile", + ], + [ + ":(glob)src/lib/messaging/**", + "src/lib/messaging/channels/slack.ts\nsrc/lib/messaging/types.ts", + ], + ]).get(pathspec), + `unexpected glob expansion: ${args.join(" ")}`, + ); + }, + ); + + expect(expanded).toEqual([ + "Dockerfile", + "agents/hermes/Dockerfile", + "agents/openclaw/manifest.yaml", + "src/lib/messaging/channels/slack.ts", + "src/lib/messaging/types.ts", + ]); + expect(calls).toEqual([ + [ + "log", + "--first-parent", + "--diff-merges=first-parent", + "--format=", + "--name-only", + EXPECTED_SHA, + "--", + ":(glob)agents/**", + ], + [ + "log", + "--first-parent", + "--diff-merges=first-parent", + "--format=", + "--name-only", + EXPECTED_SHA, + "--", + ":(glob)src/lib/messaging/**", + ], + ]); + }); + + it("fails closed when a reviewed glob is empty or Git returns an out-of-family path (#7744)", () => { + expect(() => expandBaseImagePushPaths(EXPECTED_SHA, ["agents/**"], () => "")).toThrow( + /did not match Git history/u, + ); + expect(() => + expandBaseImagePushPaths(EXPECTED_SHA, ["agents/**"], () => "scripts/escaped.sh"), + ).toThrow(/outside reviewed/u); + }); + it("binds the applicable commit to the checked-out first-parent chain (#7372)", () => { const calls: string[][] = []; const resolved = resolveFirstParentHistory(EXPECTED_SHA, ["Dockerfile.base"], (args) => { @@ -312,7 +393,9 @@ describe("base-image publication evidence", () => { }); it("collects page-two evidence and rejects duplicate or truncated pagination (#7372)", async () => { - const entries = Array.from({ length: 101 }, (_, index) => ({ id: index + 1 })); + const entries = Array.from({ length: 101 }, (_, index) => ({ + id: index + 1, + })); const pages = [ { total_count: entries.length, workflow_runs: entries.slice(0, 100) }, { total_count: entries.length, workflow_runs: entries.slice(100) }, @@ -355,7 +438,10 @@ describe("base-image publication evidence", () => { WORKFLOW_ID, ); - expect(selection).toMatchObject({ state: "ready", run: { headSha: EXPECTED_SHA } }); + expect(selection).toMatchObject({ + state: "ready", + run: { headSha: EXPECTED_SHA }, + }); }); it("prefers the graph-newest trusted run without relying on API order (#7372)", () => { @@ -376,7 +462,10 @@ describe("base-image publication evidence", () => { WORKFLOW_ID, ); - expect(selection).toMatchObject({ state: "ready", run: { id: 11, headSha: DESCENDANT_SHA } }); + expect(selection).toMatchObject({ + state: "ready", + run: { id: 11, headSha: DESCENDANT_SHA }, + }); }); it("ignores pre-rename workflow metadata outside the eligible history (#7372)", () => { @@ -434,7 +523,10 @@ describe("base-image publication evidence", () => { selectPublicationRun( runsPayload([ workflowRun(), - workflowRun({ id: RUN_ID + 1, html_url: `${RUN_URL_ROOT}/${RUN_ID + 1}` }), + workflowRun({ + id: RUN_ID + 1, + html_url: `${RUN_URL_ROOT}/${RUN_ID + 1}`, + }), ]), history(), WORKFLOW_ID, @@ -465,7 +557,10 @@ describe("base-image publication evidence", () => { run_attempt: 1, conclusion: "failure", }), - publisherJob("Build and push Hermes base image", { id: 5, run_attempt: 2 }), + publisherJob("Build and push Hermes base image", { + id: 5, + run_attempt: 2, + }), ].filter((job, index) => index !== 1); expect(() => validatePublisherJobs({ total_count: jobs.length, jobs }, run)).not.toThrow(); @@ -582,7 +677,10 @@ describe("base-image publication evidence", () => { it("retries bounded transient and rate-limited GitHub responses (#7372)", async () => { const transientResponses: Array = [ new Error("network unavailable"), - new Response("unavailable", { status: 503, headers: { "retry-after": "2" } }), + new Response("unavailable", { + status: 503, + headers: { "retry-after": "2" }, + }), new Response(JSON.stringify({ ok: true }), { status: 200 }), ]; const transientSleeps: number[] = []; diff --git a/test/managed-image-publication-workflow.test.ts b/test/managed-image-publication-workflow.test.ts new file mode 100644 index 0000000000..71f99ae3c7 --- /dev/null +++ b/test/managed-image-publication-workflow.test.ts @@ -0,0 +1,332 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import fs from "node:fs"; +import path from "node:path"; + +import { describe, expect, it } from "vitest"; +import YAML from "yaml"; + +type Step = { + env?: Record; + id?: string; + name?: string; + run?: string; + uses?: string; + with?: Record; +}; + +type MatrixEntry = { + agent?: string; + artifact_platform?: string; + base_image?: string; + display_name?: string; + dockerfile?: string; + image?: string; + platform?: string; + required_binary?: string; +}; + +type Job = { + if?: string; + needs?: string | string[]; + permissions?: Record; + "runs-on"?: string; + steps?: Step[]; + strategy?: { + "fail-fast"?: boolean; + matrix?: { include?: MatrixEntry[] }; + }; + "timeout-minutes"?: number; + uses?: string; +}; + +type Workflow = { + concurrency?: { + "cancel-in-progress"?: string | boolean; + group?: string; + }; + env?: Record; + jobs?: Record; + on?: { + push?: { + paths?: string[]; + }; + workflow_call?: unknown; + }; + permissions?: Record; +}; + +const repoRoot = path.resolve(import.meta.dirname, ".."); +const fullShaAction = /^[^@]+@[0-9a-f]{40}$/iu; +const managedInputPaths = [ + ".dockerignore", + ".github/workflows/managed-images.yaml", + "Dockerfile", + "agents/**", + "ci/npm-audit-exceptions.json", + "nemoclaw/**", + "nemoclaw-blueprint/**", + "scripts/**", + "src/lib/actions/sandbox/openshell-child-visible-credentials.v*.json", + "src/lib/messaging/**", + "src/lib/tool-disclosure.ts", + "tools/mcp-tool-discovery-runtime/**", + "tsconfig.runtime-preloads.json", +] as const; + +function readWorkflow(file: string): Workflow { + return YAML.parse( + fs.readFileSync(path.join(repoRoot, ".github", "workflows", file), "utf8"), + ) as Workflow; +} + +function required(value: T | undefined, message: string): T { + return ( + value ?? + (() => { + throw new Error(message); + })() + ); +} + +function step(job: Job, name: string): Step { + return required( + job.steps?.find((candidate) => candidate.name === name), + `managed-image workflow is missing '${name}'`, + ); +} + +function managedPublisher(workflow: Workflow): Job { + return required( + workflow.jobs?.["build-validate-and-promote"], + "managed-image workflow is missing its publisher", + ); +} + +function publicationBoundaryErrors(baseWorkflow: Workflow, managedWorkflow: Workflow): string[] { + const triggerPaths = baseWorkflow.on?.push?.paths ?? []; + const caller = required( + baseWorkflow.jobs?.["publish-managed-images"], + "base-image workflow is missing the managed-image publisher", + ); + const publisher = managedPublisher(managedWorkflow); + const steps = publisher.steps ?? []; + const build = step(publisher, "Build and push managed image by digest"); + const base = step(publisher, "Validate exact base image contract"); + const validate = step(publisher, "Validate exact managed image before promotion"); + const promote = step(publisher, "Promote validated managed image aliases"); + const workflowSource = JSON.stringify(managedWorkflow); + const validationMarkers = [ + 'mktemp -d "$RUNNER_TEMP/anonymous-docker-XXXXXX"', + 'DOCKER_CONFIG="$anonymous_config" docker pull --platform "$PLATFORM" "$reference"', + "bootstrap the GHCR package", + ".Config.Entrypoint", + ".Config.Cmd", + "/usr/local/bin/nemoclaw-start", + "/opt/nemoclaw-blueprint/blueprint.yaml", + "/usr/local/share/nemoclaw/node-tar-inventory.json", + "/usr/local/share/nemoclaw/corporate-ca.pem", + 'entry.status !== "fixed"', + '--entrypoint "$REQUIRED_BINARY"', + "io.nvidia.nemoclaw.managed-image.contract", + ]; + const promotionMarkers = [ + 'aliases=("${IMAGE}:${GITHUB_SHA}")', + 'release_tag="${GITHUB_REF#refs/tags/}"', + 'docker buildx imagetools create "${tag_args[@]}" "$REFERENCE"', + 'docker buildx imagetools inspect "$REFERENCE" --raw', + 'docker buildx imagetools inspect "$alias" --raw', + 'cmp -s "$exact_raw" "$alias_raw"', + ]; + const buildIndex = steps.indexOf(build); + const validateIndex = steps.indexOf(validate); + const promoteIndex = steps.indexOf(promote); + + return [ + ...managedInputPaths + .filter((input) => !triggerPaths.includes(input)) + .map((input) => `managed image trigger is missing ${input}`), + ...(baseWorkflow.concurrency?.group === "base-image-${{ github.ref }}" + ? [] + : ["base image concurrency must be scoped by github.ref"]), + ...(baseWorkflow.concurrency?.["cancel-in-progress"] === + "${{ !startsWith(github.ref, 'refs/tags/v') }}" + ? [] + : ["v* release runs must never be cancelled"]), + ...(caller.if?.includes("inputs.openclaw_version == ''") + ? [] + : ["custom OpenClaw base builds must not publish managed images"]), + ...(build.with?.outputs === + "type=image,name=${{ env.REGISTRY }}/${{ matrix.image }},push-by-digest=true,name-canonical=true,push=true" && + build.with.push === undefined && + build.with.tags === undefined + ? [] + : ["managed images must be pushed by digest without consumer tags"]), + ...(!workflowSource.includes("GITHUB_SHA:0:8") && !workflowSource.includes("format=short") + ? [] + : ["managed image handoff and aliases must not use short source SHAs"]), + ...(base.run?.includes('.reference == (.image + "@" + .digest)') && + base.run.includes(".sourceRevision == $revision") && + base.run.includes(".run == {id: $runId, attempt: $runAttempt}") + ? [] + : ["managed image build must consume the same-run exact base digest contract"]), + ...validationMarkers + .filter((marker) => !validate.run?.includes(marker)) + .map((marker) => `exact managed image validation is missing ${marker}`), + ...promotionMarkers + .filter((marker) => !promote.run?.includes(marker)) + .map((marker) => `managed image promotion is missing ${marker}`), + ...(buildIndex >= 0 && buildIndex < validateIndex && validateIndex < promoteIndex + ? [] + : ["managed image validation must finish before alias promotion"]), + ]; +} + +describe("complete managed-image publication workflow", () => { + it("starts after exact base contracts with complete main triggers and release-safe concurrency (#7744)", () => { + const baseWorkflow = readWorkflow("base-image.yaml"); + const managedWorkflow = readWorkflow("managed-images.yaml"); + const publisher = required( + baseWorkflow.jobs?.["publish-managed-images"], + "base-image workflow is missing the managed-image publisher", + ); + + expect(publicationBoundaryErrors(baseWorkflow, managedWorkflow)).toEqual([]); + expect(publisher).toMatchObject({ + needs: ["build-and-push", "build-and-push-openclaw"], + permissions: { + contents: "read", + packages: "write", + }, + uses: "./.github/workflows/managed-images.yaml", + }); + expect(publisher.if).toContain("github.repository == 'NVIDIA/NemoClaw'"); + expect(publisher.if).toContain("github.ref == 'refs/heads/main'"); + expect(publisher.if).toContain("startsWith(github.ref, 'refs/tags/v')"); + + const qemuPublisher = required( + baseWorkflow.jobs?.["build-and-push"], + "base-image workflow is missing Hermes and DCode publishers", + ); + expect(step(qemuPublisher, "Build and push").id).toBe("build"); + expect(step(qemuPublisher, "Export managed base image contract").run).toContain( + 'reference="${IMAGE}@${DIGEST}"', + ); + expect(step(qemuPublisher, "Upload managed base image contract").with?.name).toBe( + "managed-base-${{ matrix.agent }}", + ); + + const openClawPublisher = required( + baseWorkflow.jobs?.["build-and-push-openclaw"], + "base-image workflow is missing the OpenClaw manifest publisher", + ); + expect(step(openClawPublisher, "Create and verify multi-platform manifest").id).toBe( + "manifest", + ); + expect(step(openClawPublisher, "Export managed base image contract").env?.DIGEST).toBe( + "${{ steps.manifest.outputs.digest }}", + ); + expect(step(openClawPublisher, "Upload managed base image contract").with?.name).toBe( + "managed-base-openclaw", + ); + }); + + it("publishes every complete agent image for the initial Linux x86_64 contract (#7744)", () => { + const workflow = readWorkflow("managed-images.yaml"); + const publisher = managedPublisher(workflow); + + expect(Object.keys(workflow.on ?? {})).toEqual(["workflow_call"]); + expect(workflow.permissions).toEqual({ + contents: "read", + packages: "write", + }); + expect(publisher["runs-on"]).toBe("ubuntu-24.04"); + expect(publisher["timeout-minutes"]).toBe(90); + expect(publisher.strategy?.["fail-fast"]).toBe(false); + expect(publisher.strategy?.matrix?.include).toEqual([ + { + agent: "openclaw", + display_name: "OpenClaw", + dockerfile: "Dockerfile", + base_image: "nvidia/nemoclaw/sandbox-base", + image: "nvidia/nemoclaw/openclaw-sandbox", + platform: "linux/amd64", + artifact_platform: "linux-amd64", + required_binary: "/usr/local/bin/openclaw", + }, + { + agent: "hermes", + display_name: "Hermes", + dockerfile: "agents/hermes/Dockerfile", + base_image: "nvidia/nemoclaw/hermes-sandbox-base", + image: "nvidia/nemoclaw/hermes-sandbox", + platform: "linux/amd64", + artifact_platform: "linux-amd64", + required_binary: "/usr/local/bin/hermes", + }, + { + agent: "langchain-deepagents-code", + display_name: "Deep Agents Code", + dockerfile: "agents/langchain-deepagents-code/Dockerfile", + base_image: "nvidia/nemoclaw/langchain-deepagents-code-sandbox-base", + image: "nvidia/nemoclaw/langchain-deepagents-code-sandbox", + platform: "linux/amd64", + artifact_platform: "linux-amd64", + required_binary: "/usr/local/bin/dcode", + }, + ]); + }); + + it("pins actions, validates exact digests, and records the promoted image contract (#7744)", () => { + const workflow = readWorkflow("managed-images.yaml"); + const publisher = managedPublisher(workflow); + const steps = publisher.steps ?? []; + + for (const action of steps.filter((candidate) => candidate.uses)) { + expect(action.uses, action.name).toMatch(fullShaAction); + } + expect(step(publisher, "Checkout").with?.["persist-credentials"]).toBe(false); + expect(step(publisher, "Download exact base image contract").with).toMatchObject({ + name: "managed-base-${{ matrix.agent }}", + path: "${{ runner.temp }}/managed-base-contract", + }); + + const guard = step(publisher, "Validate production build args"); + const build = step(publisher, "Build and push managed image by digest"); + expect(steps.indexOf(guard)).toBeLessThan(steps.indexOf(build)); + expect(guard.run).toContain('scripts/check-production-build-args.sh "${build_args[@]}"'); + expect(build.uses).toBe("docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a"); + expect(build.with).toMatchObject({ + context: ".", + file: "${{ matrix.dockerfile }}", + platforms: "${{ matrix.platform }}", + "build-args": "BASE_IMAGE=${{ steps.base.outputs.ref }}", + provenance: "mode=max", + sbom: true, + }); + expect(build.with?.push).toBeUndefined(); + expect(build.with?.tags).toBeUndefined(); + expect(build.with?.labels).toContain("org.opencontainers.image.revision=${{ github.sha }}"); + expect(build.with?.labels).toContain("io.nvidia.nemoclaw.managed-image.contract=1"); + + const contract = step(publisher, "Export managed image contract"); + for (const marker of [ + "--arg baseReference", + "--arg digest", + "--arg platform", + "--arg revision", + "--argjson runAttempt", + "--argjson runId", + "contractVersion: 1", + ]) { + expect(contract.run).toContain(marker); + } + expect(step(publisher, "Upload managed image contract").with).toMatchObject({ + name: "managed-image-${{ matrix.agent }}-${{ matrix.artifact_platform }}", + path: "${{ runner.temp }}/managed-image-contract/contract.json", + "if-no-files-found": "error", + "retention-days": 90, + }); + }); +}); diff --git a/tools/e2e/base-image-publication.mts b/tools/e2e/base-image-publication.mts index a10335dee0..32bf1d41a3 100644 --- a/tools/e2e/base-image-publication.mts +++ b/tools/e2e/base-image-publication.mts @@ -22,6 +22,18 @@ const REQUEST_TIMEOUT_MS = 20_000; const MAX_RETRY_DELAY_MS = 10_000; const SHA_PATTERN = /^[0-9a-f]{40}$/u; const SAFE_PATH_PATTERN = /^[A-Za-z0-9._/-]+$/u; +const REVIEWED_PATH_GLOBS = new Map([ + ["agents/**", /^agents\/.+$/u], + ["nemoclaw/**", /^nemoclaw\/.+$/u], + ["nemoclaw-blueprint/**", /^nemoclaw-blueprint\/.+$/u], + ["scripts/**", /^scripts\/.+$/u], + [ + "src/lib/actions/sandbox/openshell-child-visible-credentials.v*.json", + /^src\/lib\/actions\/sandbox\/openshell-child-visible-credentials[.]v[^/]*[.]json$/u, + ], + ["src/lib/messaging/**", /^src\/lib\/messaging\/.+$/u], + ["tools/mcp-tool-discovery-runtime/**", /^tools\/mcp-tool-discovery-runtime\/.+$/u], +]); const PENDING_RUN_STATUSES = new Set(["requested", "waiting", "pending", "queued", "in_progress"]); const COMPLETED_CONCLUSIONS = new Set([ "action_required", @@ -127,6 +139,7 @@ function parseQuotedPath(raw: string, lineNumber: number): string { if (typeof value !== "string" || value.length === 0 || value.trim() !== value) { throw new Error(`base-image push path on line ${lineNumber} must be a non-empty exact path`); } + if (REVIEWED_PATH_GLOBS.has(value)) return value; if ( !SAFE_PATH_PATTERN.test(value) || value.startsWith("/") || @@ -141,9 +154,9 @@ function parseQuotedPath(raw: string, lineNumber: number): string { } /** - * Read the literal path list that controls the publisher without requiring a - * dependency install in the preflight job. Deliberately reject YAML features - * such as flow lists, aliases, globs, and folded scalars instead of guessing. + * Read the controlled path list without requiring a dependency install in the + * preflight job. Deliberately reject YAML features such as flow lists, aliases, + * unreviewed globs, and folded scalars instead of guessing. */ export function parseBaseImagePushPaths(source: string): string[] { const lines = source.split(/\r?\n/u); @@ -226,6 +239,57 @@ function defaultGit(args: string[]): string { }).trim(); } +export function expandBaseImagePushPaths( + expectedSha: string, + paths: readonly string[], + runGit: (args: string[]) => string = defaultGit, +): string[] { + sha(expectedSha, "expected SHA"); + const expanded = new Set(); + + for (const path of paths) { + const matcher = REVIEWED_PATH_GLOBS.get(path); + if (!matcher) { + expanded.add(path); + continue; + } + + const matches = runGit([ + "log", + "--first-parent", + "--diff-merges=first-parent", + "--format=", + "--name-only", + expectedSha, + "--", + `:(glob)${path}`, + ]) + .split(/\r?\n/u) + .filter((candidate) => candidate.length > 0); + if (matches.length === 0) { + throw new Error(`reviewed base-image push glob did not match Git history: ${path}`); + } + for (const candidate of matches) { + if ( + !SAFE_PATH_PATTERN.test(candidate) || + candidate.startsWith("/") || + candidate.includes("//") || + candidate + .split("/") + .some((segment) => segment === "" || segment === "." || segment === "..") + ) { + throw new Error(`reviewed base-image push glob expanded to an unsafe path: ${candidate}`); + } + if (!matcher.test(candidate)) { + throw new Error(`Git returned a path outside reviewed base-image push glob ${path}`); + } + expanded.add(candidate); + } + } + + return [...expanded].sort(); +} + export function resolveFirstParentHistory( expectedSha: string, paths: readonly string[], @@ -243,6 +307,10 @@ export function resolveFirstParentHistory( if (runGit(["rev-parse", "--is-shallow-repository"]) !== "false") { throw new Error("base-image publication gate requires a complete Git history"); } + const expandedPaths = expandBaseImagePushPaths(expectedSha, paths, runGit); + if (expandedPaths.length === 0) { + throw new Error("base-image push paths did not resolve to any Git paths"); + } const relevantSha = runGit([ "log", @@ -252,7 +320,7 @@ export function resolveFirstParentHistory( "--format=%H", expectedSha, "--", - ...paths, + ...expandedPaths, ]); sha(relevantSha, "latest applicable base-image commit"); @@ -332,7 +400,15 @@ function validateRun(value: unknown, index: number, expectedWorkflowId: number): throw new Error(`workflow run ${index} pending state is invalid`); } } - return { id, attempt, workflowId: expectedWorkflowId, headSha, status, conclusion, url }; + return { + id, + attempt, + workflowId: expectedWorkflowId, + headSha, + status, + conclusion, + url, + }; } export function selectPublicationRun( @@ -417,7 +493,11 @@ export function validatePublisherJobs(payload: unknown, run: PublicationRun): vo if (occurrences.some((occurrence) => occurrence.attempt === attempt)) { throw new Error(`publisher job ${job.name} is duplicated in attempt ${attempt}; ${run.url}`); } - occurrences.push({ attempt, status: job.status, conclusion: job.conclusion }); + occurrences.push({ + attempt, + status: job.status, + conclusion: job.conclusion, + }); jobsByName.set(job.name, occurrences); } From 65bb1e18d4be033fd03ce2e9f01e5063a7153729 Mon Sep 17 00:00:00 2001 From: Aaron Erickson Date: Wed, 29 Jul 2026 12:09:33 -0700 Subject: [PATCH 5/7] fix(images): promote managed cohorts atomically Signed-off-by: Aaron Erickson --- .github/workflows/managed-images.yaml | 72 +++---------------- ...managed-image-publication-workflow.test.ts | 46 ++++++++---- 2 files changed, 42 insertions(+), 76 deletions(-) diff --git a/.github/workflows/managed-images.yaml b/.github/workflows/managed-images.yaml index ac0cbcba2c..68cbf97795 100644 --- a/.github/workflows/managed-images.yaml +++ b/.github/workflows/managed-images.yaml @@ -1,10 +1,12 @@ # SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. # SPDX-License-Identifier: Apache-2.0 -# Publish complete managed images from exact base-image contracts. A later -# onboarding slice will apply runtime profiles and consume their public release -# aliases or exact digests. The initial platform is Linux x86_64; the OCI image -# contract remains neutral across Podman, Docker, and future compute drivers. +# Publish complete managed images from exact base-image contracts. Each matrix +# lane pushes and validates only an immutable digest, then exports its contract. +# A later aggregate publisher will apply runtime profiles and move consumer +# aliases only after the complete all-agent cohort passes. The initial platform +# is Linux x86_64; the OCI image contract remains neutral across Podman, Docker, +# and future compute drivers. name: Images / Managed Images @@ -19,8 +21,8 @@ env: REGISTRY: ghcr.io jobs: - build-validate-and-promote: - name: Build, validate, and promote ${{ matrix.display_name }} managed image + build-and-validate: + name: Build and validate ${{ matrix.display_name }} managed image runs-on: ubuntu-24.04 timeout-minutes: 90 strategy: @@ -151,7 +153,7 @@ jobs: provenance: mode=max sbom: true - - name: Validate exact managed image before promotion + - name: Validate exact managed image id: validate shell: bash env: @@ -256,56 +258,9 @@ jobs: fi printf 'reference=%s\n' "$reference" >> "$GITHUB_OUTPUT" - - name: Promote validated managed image aliases - id: promote - shell: bash - env: - IMAGE: ${{ env.REGISTRY }}/${{ matrix.image }} - REFERENCE: ${{ steps.validate.outputs.reference }} - run: | - set -euo pipefail - if [[ ! "$GITHUB_SHA" =~ ^[0-9a-f]{40}$ ]]; then - echo "ERROR: source revision must be a full 40-character SHA." >&2 - exit 1 - fi - aliases=("${IMAGE}:${GITHUB_SHA}") - if [[ "$GITHUB_REF" == refs/tags/* ]]; then - release_tag="${GITHUB_REF#refs/tags/}" - if [[ ! "$release_tag" =~ ^v[0-9]+([.][0-9]+){1,3}([-.][0-9A-Za-z][0-9A-Za-z.-]*)?$ ]]; then - echo "ERROR: release tag is not a supported version alias: $release_tag" >&2 - exit 1 - fi - aliases+=("${IMAGE}:${release_tag}") - elif [ "$GITHUB_REF" != "refs/heads/main" ]; then - echo "ERROR: managed images may only be promoted from main or a v* release tag." >&2 - exit 1 - fi - - tag_args=() - for alias in "${aliases[@]}"; do - tag_args+=(--tag "$alias") - done - docker buildx imagetools create "${tag_args[@]}" "$REFERENCE" - - exact_raw="$RUNNER_TEMP/managed-image-exact.raw" - docker buildx imagetools inspect "$REFERENCE" --raw > "$exact_raw" - for alias in "${aliases[@]}"; do - alias_raw="$RUNNER_TEMP/managed-image-alias-${alias##*:}.raw" - docker buildx imagetools inspect "$alias" --raw > "$alias_raw" - if ! cmp -s "$exact_raw" "$alias_raw"; then - echo "ERROR: promoted alias does not resolve to the validated raw manifest: $alias" >&2 - exit 1 - fi - done - - aliases_file="$RUNNER_TEMP/managed-image-aliases.json" - printf '%s\n' "${aliases[@]}" | jq -R . | jq -s . > "$aliases_file" - printf 'aliases_file=%s\n' "$aliases_file" >> "$GITHUB_OUTPUT" - - name: Export managed image contract env: AGENT: ${{ matrix.agent }} - ALIASES_FILE: ${{ steps.promote.outputs.aliases_file }} BASE_REFERENCE: ${{ steps.base.outputs.ref }} DIGEST: ${{ steps.build.outputs.digest }} IMAGE: ${{ env.REGISTRY }}/${{ matrix.image }} @@ -324,7 +279,6 @@ jobs: --arg reference "$REFERENCE" \ --arg repository "$GITHUB_REPOSITORY" \ --arg revision "$GITHUB_SHA" \ - --argjson aliases "$(cat "$ALIASES_FILE")" \ --argjson runAttempt "$GITHUB_RUN_ATTEMPT" \ --argjson runId "$GITHUB_RUN_ID" \ '{ @@ -342,8 +296,7 @@ jobs: run: { id: $runId, attempt: $runAttempt - }, - aliases: $aliases + } }' > "$contract_dir/contract.json" jq -e \ --arg revision "$GITHUB_SHA" \ @@ -355,10 +308,7 @@ jobs: and (.baseReference | test("@sha256:[0-9a-f]{64}$")) and .source.revision == $revision and .run == {id: $runId, attempt: $runAttempt} - and ( - (.image + ":" + $revision) as $revisionAlias - | (.aliases | index($revisionAlias)) != null - )' \ + and (has("aliases") | not)' \ "$contract_dir/contract.json" >/dev/null - name: Upload managed image contract diff --git a/test/managed-image-publication-workflow.test.ts b/test/managed-image-publication-workflow.test.ts index 71f99ae3c7..82b3128c4c 100644 --- a/test/managed-image-publication-workflow.test.ts +++ b/test/managed-image-publication-workflow.test.ts @@ -99,7 +99,7 @@ function step(job: Job, name: string): Step { function managedPublisher(workflow: Workflow): Job { return required( - workflow.jobs?.["build-validate-and-promote"], + workflow.jobs?.["build-and-validate"], "managed-image workflow is missing its publisher", ); } @@ -114,8 +114,7 @@ function publicationBoundaryErrors(baseWorkflow: Workflow, managedWorkflow: Work const steps = publisher.steps ?? []; const build = step(publisher, "Build and push managed image by digest"); const base = step(publisher, "Validate exact base image contract"); - const validate = step(publisher, "Validate exact managed image before promotion"); - const promote = step(publisher, "Promote validated managed image aliases"); + const validate = step(publisher, "Validate exact managed image"); const workflowSource = JSON.stringify(managedWorkflow); const validationMarkers = [ 'mktemp -d "$RUNNER_TEMP/anonymous-docker-XXXXXX"', @@ -131,17 +130,15 @@ function publicationBoundaryErrors(baseWorkflow: Workflow, managedWorkflow: Work '--entrypoint "$REQUIRED_BINARY"', "io.nvidia.nemoclaw.managed-image.contract", ]; - const promotionMarkers = [ + const forbiddenPerLanePromotionMarkers = [ 'aliases=("${IMAGE}:${GITHUB_SHA}")', 'release_tag="${GITHUB_REF#refs/tags/}"', - 'docker buildx imagetools create "${tag_args[@]}" "$REFERENCE"', - 'docker buildx imagetools inspect "$REFERENCE" --raw', - 'docker buildx imagetools inspect "$alias" --raw', - 'cmp -s "$exact_raw" "$alias_raw"', + "docker buildx imagetools create", + "docker tag ", + "docker push ", ]; const buildIndex = steps.indexOf(build); const validateIndex = steps.indexOf(validate); - const promoteIndex = steps.indexOf(promote); return [ ...managedInputPaths @@ -174,12 +171,12 @@ function publicationBoundaryErrors(baseWorkflow: Workflow, managedWorkflow: Work ...validationMarkers .filter((marker) => !validate.run?.includes(marker)) .map((marker) => `exact managed image validation is missing ${marker}`), - ...promotionMarkers - .filter((marker) => !promote.run?.includes(marker)) - .map((marker) => `managed image promotion is missing ${marker}`), - ...(buildIndex >= 0 && buildIndex < validateIndex && validateIndex < promoteIndex + ...forbiddenPerLanePromotionMarkers + .filter((marker) => workflowSource.includes(marker)) + .map((marker) => `per-agent lane must not publish mutable alias with ${marker}`), + ...(buildIndex >= 0 && buildIndex < validateIndex ? [] - : ["managed image validation must finish before alias promotion"]), + : ["managed image validation must follow its immutable digest build"]), ]; } @@ -278,7 +275,7 @@ describe("complete managed-image publication workflow", () => { ]); }); - it("pins actions, validates exact digests, and records the promoted image contract (#7744)", () => { + it("pins actions, validates exact digests, and records the immutable image contract (#7744)", () => { const workflow = readWorkflow("managed-images.yaml"); const publisher = managedPublisher(workflow); const steps = publisher.steps ?? []; @@ -329,4 +326,23 @@ describe("complete managed-image publication workflow", () => { "retention-days": 90, }); }); + + it("cannot publish a public mutable alias from an individual agent lane (#7744)", () => { + const workflow = readWorkflow("managed-images.yaml"); + const publisher = managedPublisher(workflow); + const steps = publisher.steps ?? []; + const source = steps.map((candidate) => candidate.run ?? "").join("\n"); + const contract = step(publisher, "Export managed image contract"); + + expect(publisher.strategy?.matrix?.include).toHaveLength(3); + expect(steps.map((candidate) => candidate.name)).not.toContain( + "Promote validated managed image aliases", + ); + expect(source).not.toContain('aliases=("${IMAGE}:${GITHUB_SHA}")'); + expect(source).not.toContain('release_tag="${GITHUB_REF#refs/tags/}"'); + expect(source).not.toContain("docker buildx imagetools create"); + expect(source).not.toMatch(/(?:^|\s)docker\s+(?:tag|push)\s/u); + expect(contract.run).toContain('(has("aliases") | not)'); + expect(contract.run).not.toContain("aliases:"); + }); }); From 22bf66de25f208bbc72fed7e27e1091f7748e310 Mon Sep 17 00:00:00 2001 From: Aaron Erickson Date: Wed, 29 Jul 2026 22:10:36 -0700 Subject: [PATCH 6/7] ci(images): publish managed images for arm64 Signed-off-by: Aaron Erickson --- .github/workflows/base-image.yaml | 110 ++- .github/workflows/managed-images.yaml | 740 +++++++++++++++++- .../managed-image-publication-barrier.ts | 228 ++++++ ...managed-image-publication-workflow.test.ts | 335 +++++++- 4 files changed, 1372 insertions(+), 41 deletions(-) create mode 100644 test/helpers/managed-image-publication-barrier.ts diff --git a/.github/workflows/base-image.yaml b/.github/workflows/base-image.yaml index a3f59d20a5..5b166d1b25 100644 --- a/.github/workflows/base-image.yaml +++ b/.github/workflows/base-image.yaml @@ -295,7 +295,44 @@ jobs: exit 1 fi reference="${IMAGE}@${DIGEST}" - docker buildx imagetools inspect "$reference" >/dev/null + raw_manifest="$RUNNER_TEMP/managed-base-manifest.raw" + docker buildx imagetools inspect "$reference" --raw > "$raw_manifest" + platform_digests="$( + jq -ce ' + if ( + .mediaType == "application/vnd.oci.image.index.v1+json" or + .mediaType == "application/vnd.docker.distribution.manifest.list.v2+json" + ) then + [ + .manifests[] + | select( + .platform.os == "linux" and + ( + .platform.architecture == "amd64" or + .platform.architecture == "arm64" + ) + ) + | {key: ("linux/" + .platform.architecture), value: .digest} + ] + | if ( + length == 2 and + (map(.key) | sort) == ["linux/amd64", "linux/arm64"] and + (map(.value) | all(test("^sha256:[0-9a-f]{64}$"))) + ) + then from_entries + else error("base manifest does not contain one exact descriptor per platform") + end + else + error("base reference is not a multi-platform image index") + end + ' "$raw_manifest" + )" + platform_references="$( + jq -cn \ + --arg image "$IMAGE" \ + --argjson digests "$platform_digests" \ + '$digests | with_entries(.value = ($image + "@" + .value))' + )" contract_dir="$RUNNER_TEMP/managed-base-contract" mkdir -p "$contract_dir" @@ -305,6 +342,8 @@ jobs: --arg image "$IMAGE" \ --arg reference "$reference" \ --arg revision "$GITHUB_SHA" \ + --argjson platformDigests "$platform_digests" \ + --argjson platformReferences "$platform_references" \ --argjson runAttempt "$GITHUB_RUN_ATTEMPT" \ --argjson runId "$GITHUB_RUN_ID" \ '{ @@ -314,6 +353,8 @@ jobs: digest: $digest, reference: $reference, platforms: ["linux/amd64", "linux/arm64"], + platformDigests: $platformDigests, + platformReferences: $platformReferences, sourceRevision: $revision, run: { id: $runId, @@ -325,7 +366,17 @@ jobs: and (.sourceRevision | test("^[0-9a-f]{40}$")) and (.digest | test("^sha256:[0-9a-f]{64}$")) and .reference == (.image + "@" + .digest) - and .platforms == ["linux/amd64", "linux/arm64"]' \ + and .platforms == ["linux/amd64", "linux/arm64"] + and (.platformDigests | keys | sort) == .platforms + and (.platformReferences | keys | sort) == .platforms + and ([ + .platforms[] as $platform + | ( + (.platformDigests[$platform] | test("^sha256:[0-9a-f]{64}$")) + and .platformReferences[$platform] == + (.image + "@" + .platformDigests[$platform]) + ) + ] | all)' \ "$contract_dir/contract.json" >/dev/null - name: Upload managed base image contract @@ -446,7 +497,44 @@ jobs: exit 1 fi reference="${IMAGE}@${DIGEST}" - docker buildx imagetools inspect "$reference" >/dev/null + raw_manifest="$RUNNER_TEMP/managed-openclaw-base-manifest.raw" + docker buildx imagetools inspect "$reference" --raw > "$raw_manifest" + platform_digests="$( + jq -ce ' + if ( + .mediaType == "application/vnd.oci.image.index.v1+json" or + .mediaType == "application/vnd.docker.distribution.manifest.list.v2+json" + ) then + [ + .manifests[] + | select( + .platform.os == "linux" and + ( + .platform.architecture == "amd64" or + .platform.architecture == "arm64" + ) + ) + | {key: ("linux/" + .platform.architecture), value: .digest} + ] + | if ( + length == 2 and + (map(.key) | sort) == ["linux/amd64", "linux/arm64"] and + (map(.value) | all(test("^sha256:[0-9a-f]{64}$"))) + ) + then from_entries + else error("OpenClaw base manifest does not contain one exact descriptor per platform") + end + else + error("OpenClaw base reference is not a multi-platform image index") + end + ' "$raw_manifest" + )" + platform_references="$( + jq -cn \ + --arg image "$IMAGE" \ + --argjson digests "$platform_digests" \ + '$digests | with_entries(.value = ($image + "@" + .value))' + )" contract_dir="$RUNNER_TEMP/managed-base-contract" mkdir -p "$contract_dir" @@ -455,6 +543,8 @@ jobs: --arg image "$IMAGE" \ --arg reference "$reference" \ --arg revision "$GITHUB_SHA" \ + --argjson platformDigests "$platform_digests" \ + --argjson platformReferences "$platform_references" \ --argjson runAttempt "$GITHUB_RUN_ATTEMPT" \ --argjson runId "$GITHUB_RUN_ID" \ '{ @@ -464,6 +554,8 @@ jobs: digest: $digest, reference: $reference, platforms: ["linux/amd64", "linux/arm64"], + platformDigests: $platformDigests, + platformReferences: $platformReferences, sourceRevision: $revision, run: { id: $runId, @@ -476,7 +568,17 @@ jobs: and (.sourceRevision | test("^[0-9a-f]{40}$")) and (.digest | test("^sha256:[0-9a-f]{64}$")) and .reference == (.image + "@" + .digest) - and .platforms == ["linux/amd64", "linux/arm64"]' \ + and .platforms == ["linux/amd64", "linux/arm64"] + and (.platformDigests | keys | sort) == .platforms + and (.platformReferences | keys | sort) == .platforms + and ([ + .platforms[] as $platform + | ( + (.platformDigests[$platform] | test("^sha256:[0-9a-f]{64}$")) + and .platformReferences[$platform] == + (.image + "@" + .platformDigests[$platform]) + ) + ] | all)' \ "$contract_dir/contract.json" >/dev/null - name: Upload managed base image contract diff --git a/.github/workflows/managed-images.yaml b/.github/workflows/managed-images.yaml index 68cbf97795..bb64cbb452 100644 --- a/.github/workflows/managed-images.yaml +++ b/.github/workflows/managed-images.yaml @@ -1,12 +1,14 @@ # SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. # SPDX-License-Identifier: Apache-2.0 -# Publish complete managed images from exact base-image contracts. Each matrix -# lane pushes and validates only an immutable digest, then exports its contract. -# A later aggregate publisher will apply runtime profiles and move consumer -# aliases only after the complete all-agent cohort passes. The initial platform -# is Linux x86_64; the OCI image contract remains neutral across Podman, Docker, -# and future compute drivers. +# Publish complete managed images from exact base-image contracts. Every +# agent/platform lane pushes and validates only an immutable digest. The +# aggregate publisher then proves that all three agents have both supported +# Linux architectures before it stages any cohort alias. Only after all staged +# aliases resolve to the exact validated manifests does the single OpenClaw +# cohort pointer move. Consumers use that pointer to discover the matching +# Hermes and Deep Agents Code cohort aliases, so a failed run cannot expose a +# mixed-agent or mixed-architecture cohort. name: Images / Managed Images @@ -22,14 +24,15 @@ env: jobs: build-and-validate: - name: Build and validate ${{ matrix.display_name }} managed image - runs-on: ubuntu-24.04 - timeout-minutes: 90 + name: Build and validate ${{ matrix.display_name }} managed image (${{ matrix.arch }}) + runs-on: ${{ matrix.runner }} + timeout-minutes: 120 strategy: fail-fast: false matrix: include: - agent: openclaw + arch: amd64 display_name: OpenClaw dockerfile: Dockerfile base_image: nvidia/nemoclaw/sandbox-base @@ -37,7 +40,19 @@ jobs: platform: linux/amd64 artifact_platform: linux-amd64 required_binary: /usr/local/bin/openclaw + runner: ubuntu-24.04 + - agent: openclaw + arch: arm64 + display_name: OpenClaw + dockerfile: Dockerfile + base_image: nvidia/nemoclaw/sandbox-base + image: nvidia/nemoclaw/openclaw-sandbox + platform: linux/arm64 + artifact_platform: linux-arm64 + required_binary: /usr/local/bin/openclaw + runner: ubuntu-24.04-arm - agent: hermes + arch: amd64 display_name: Hermes dockerfile: agents/hermes/Dockerfile base_image: nvidia/nemoclaw/hermes-sandbox-base @@ -45,7 +60,19 @@ jobs: platform: linux/amd64 artifact_platform: linux-amd64 required_binary: /usr/local/bin/hermes + runner: ubuntu-24.04 + - agent: hermes + arch: arm64 + display_name: Hermes + dockerfile: agents/hermes/Dockerfile + base_image: nvidia/nemoclaw/hermes-sandbox-base + image: nvidia/nemoclaw/hermes-sandbox + platform: linux/arm64 + artifact_platform: linux-arm64 + required_binary: /usr/local/bin/hermes + runner: ubuntu-24.04-arm - agent: langchain-deepagents-code + arch: amd64 display_name: Deep Agents Code dockerfile: agents/langchain-deepagents-code/Dockerfile base_image: nvidia/nemoclaw/langchain-deepagents-code-sandbox-base @@ -53,12 +80,26 @@ jobs: platform: linux/amd64 artifact_platform: linux-amd64 required_binary: /usr/local/bin/dcode + runner: ubuntu-24.04 + - agent: langchain-deepagents-code + arch: arm64 + display_name: Deep Agents Code + dockerfile: agents/langchain-deepagents-code/Dockerfile + base_image: nvidia/nemoclaw/langchain-deepagents-code-sandbox-base + image: nvidia/nemoclaw/langchain-deepagents-code-sandbox + platform: linux/arm64 + artifact_platform: linux-arm64 + required_binary: /usr/local/bin/dcode + runner: ubuntu-24.04-arm steps: - name: Checkout uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4.2.0 + - name: Download exact base image contract uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 with: @@ -92,6 +133,8 @@ jobs: "contractVersion", "digest", "image", + "platformDigests", + "platformReferences", "platforms", "reference", "run", @@ -102,7 +145,12 @@ jobs: and .image == $image and (.digest | test("^sha256:[0-9a-f]{64}$")) and .reference == (.image + "@" + .digest) - and (.platforms | index($platform)) != null + and .platforms == ["linux/amd64", "linux/arm64"] + and (.platformDigests | keys | sort) == .platforms + and (.platformReferences | keys | sort) == .platforms + and (.platformDigests[$platform] | test("^sha256:[0-9a-f]{64}$")) + and .platformReferences[$platform] == + (.image + "@" + .platformDigests[$platform]) and .sourceRevision == $revision and .run == {id: $runId, attempt: $runAttempt} ' "$CONTRACT" >/dev/null @@ -110,10 +158,11 @@ jobs: echo "ERROR: exact base image contract failed closed validation." >&2 exit 1 fi - printf 'ref=%s\n' "$(jq -r '.reference' "$CONTRACT")" >> "$GITHUB_OUTPUT" - - - name: Set up Docker Buildx - uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4.2.0 + platform_reference="$( + jq -er --arg platform "$PLATFORM" '.platformReferences[$platform]' "$CONTRACT" + )" + docker buildx imagetools inspect "$platform_reference" >/dev/null + printf 'ref=%s\n' "$platform_reference" >> "$GITHUB_OUTPUT" - name: Log in to GHCR uses: docker/login-action@af1e73f918a031802d376d3c8bbc3fe56130a9b0 # v4.4.0 @@ -147,6 +196,7 @@ jobs: io.nvidia.nemoclaw.agent=${{ matrix.agent }} io.nvidia.nemoclaw.managed-image.contract=1 io.nvidia.nemoclaw.managed-image.platform=${{ matrix.platform }} + io.nvidia.nemoclaw.managed-image.cohort=ghrun-${{ github.run_id }}-${{ github.run_attempt }} build-args: BASE_IMAGE=${{ steps.base.outputs.ref }} cache-from: type=registry,ref=${{ env.REGISTRY }}/${{ matrix.image }}:buildcache-${{ matrix.artifact_platform }} cache-to: type=registry,ref=${{ env.REGISTRY }}/${{ matrix.image }}:buildcache-${{ matrix.artifact_platform }},mode=max @@ -161,6 +211,7 @@ jobs: DIGEST: ${{ steps.build.outputs.digest }} IMAGE: ${{ env.REGISTRY }}/${{ matrix.image }} PLATFORM: ${{ matrix.platform }} + PUBLICATION_COHORT: ghrun-${{ github.run_id }}-${{ github.run_attempt }} REQUIRED_BINARY: ${{ matrix.required_binary }} run: | set -euo pipefail @@ -204,6 +255,11 @@ jobs: --format '{{index .Config.Labels "io.nvidia.nemoclaw.managed-image.platform"}}' \ "$reference" )" + cohort_label="$( + docker image inspect \ + --format '{{index .Config.Labels "io.nvidia.nemoclaw.managed-image.cohort"}}' \ + "$reference" + )" revision_label="$( docker image inspect \ --format '{{index .Config.Labels "org.opencontainers.image.revision"}}' \ @@ -212,6 +268,7 @@ jobs: if [ "$agent_label" != "$AGENT" ] || [ "$contract_label" != "1" ] || [ "$platform_label" != "$PLATFORM" ] || + [ "$cohort_label" != "$PUBLICATION_COHORT" ] || [ "$revision_label" != "$GITHUB_SHA" ]; then echo "ERROR: managed image contract labels do not match the build identity." >&2 exit 1 @@ -258,17 +315,35 @@ jobs: fi printf 'reference=%s\n' "$reference" >> "$GITHUB_OUTPUT" - - name: Export managed image contract + - name: Export validated managed image candidate env: AGENT: ${{ matrix.agent }} BASE_REFERENCE: ${{ steps.base.outputs.ref }} DIGEST: ${{ steps.build.outputs.digest }} IMAGE: ${{ env.REGISTRY }}/${{ matrix.image }} PLATFORM: ${{ matrix.platform }} + PUBLICATION_COHORT: ghrun-${{ github.run_id }}-${{ github.run_attempt }} REFERENCE: ${{ steps.validate.outputs.reference }} run: | set -euo pipefail - contract_dir="$RUNNER_TEMP/managed-image-contract" + if [[ ! "$GITHUB_SHA" =~ ^[0-9a-f]{40}$ ]] || + [[ ! "$PUBLICATION_COHORT" =~ ^ghrun-[1-9][0-9]{0,19}-[1-9][0-9]{0,9}$ ]]; then + echo "ERROR: managed image publication identity is invalid." >&2 + exit 1 + fi + release_tag="" + if [[ "$GITHUB_REF" == refs/tags/* ]]; then + release_tag="${GITHUB_REF#refs/tags/}" + if [[ ! "$release_tag" =~ ^v[0-9]+([.][0-9]+){1,3}([-.][0-9A-Za-z][0-9A-Za-z.-]*)?$ ]]; then + echo "ERROR: release tag is not a supported version identity: $release_tag" >&2 + exit 1 + fi + elif [ "$GITHUB_REF" != "refs/heads/main" ]; then + echo "ERROR: managed images may only be published from main or a v* release tag." >&2 + exit 1 + fi + + contract_dir="$RUNNER_TEMP/managed-image-candidate" mkdir -p "$contract_dir" jq -n \ --arg agent "$AGENT" \ @@ -276,45 +351,664 @@ jobs: --arg digest "$DIGEST" \ --arg image "$IMAGE" \ --arg platform "$PLATFORM" \ + --arg cohort "$PUBLICATION_COHORT" \ --arg reference "$REFERENCE" \ + --arg ref "$GITHUB_REF" \ + --arg release "$release_tag" \ --arg repository "$GITHUB_REPOSITORY" \ --arg revision "$GITHUB_SHA" \ --argjson runAttempt "$GITHUB_RUN_ATTEMPT" \ --argjson runId "$GITHUB_RUN_ID" \ '{ contractVersion: 1, + phase: "candidate", agent: $agent, image: $image, digest: $digest, reference: $reference, baseReference: $baseReference, platform: $platform, + attestations: { + provenance: "mode=max", + sbom: true + }, source: { repository: $repository, - revision: $revision + revision: $revision, + ref: $ref, + cohort: $cohort }, run: { id: $runId, attempt: $runAttempt - } + }, + release: (if $release == "" then null else $release end) }' > "$contract_dir/contract.json" jq -e \ + --arg agent "$AGENT" \ + --arg image "$IMAGE" \ + --arg platform "$PLATFORM" \ + --arg cohort "$PUBLICATION_COHORT" \ + --arg ref "$GITHUB_REF" \ + --arg release "$release_tag" \ + --arg repository "$GITHUB_REPOSITORY" \ --arg revision "$GITHUB_SHA" \ --argjson runAttempt "$GITHUB_RUN_ATTEMPT" \ --argjson runId "$GITHUB_RUN_ID" \ - '.contractVersion == 1 + '(keys | sort) == [ + "agent", + "attestations", + "baseReference", + "contractVersion", + "digest", + "image", + "phase", + "platform", + "reference", + "release", + "run", + "source" + ] + and .contractVersion == 1 + and .phase == "candidate" + and .agent == $agent + and .image == $image + and .platform == $platform and (.digest | test("^sha256:[0-9a-f]{64}$")) and .reference == (.image + "@" + .digest) and (.baseReference | test("@sha256:[0-9a-f]{64}$")) - and .source.revision == $revision + and .attestations == {provenance: "mode=max", sbom: true} + and .source == { + repository: $repository, + revision: $revision, + ref: $ref, + cohort: $cohort + } and .run == {id: $runId, attempt: $runAttempt} + and .release == (if $release == "" then null else $release end) and (has("aliases") | not)' \ "$contract_dir/contract.json" >/dev/null - - name: Upload managed image contract + - name: Upload validated managed image candidate + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: managed-image-candidate-${{ matrix.agent }}-${{ matrix.artifact_platform }} + path: ${{ runner.temp }}/managed-image-candidate/contract.json + if-no-files-found: error + retention-days: 1 + + promote: + name: Promote complete multi-platform managed image cohort + needs: build-and-validate + runs-on: ubuntu-24.04 + timeout-minutes: 30 + permissions: + contents: read + packages: write + steps: + - name: Download all validated managed image candidates + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + pattern: managed-image-candidate-* + path: ${{ runner.temp }}/managed-image-candidates + merge-multiple: false + + # This is the all-agent/all-architecture publication barrier. It fails + # closed before registry authentication or any alias operation. + - name: Validate complete managed image candidate set + id: candidates + shell: bash + env: + CANDIDATE_ROOT: ${{ runner.temp }}/managed-image-candidates + run: | + set -euo pipefail + if [[ ! "$GITHUB_SHA" =~ ^[0-9a-f]{40}$ ]]; then + echo "ERROR: source revision must be a full 40-character SHA." >&2 + exit 1 + fi + expected_cohort="ghrun-${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}" + if [[ ! "$expected_cohort" =~ ^ghrun-[1-9][0-9]{0,19}-[1-9][0-9]{0,9}$ ]]; then + echo "ERROR: publication cohort is invalid: $expected_cohort" >&2 + exit 1 + fi + expected_release="" + if [[ "$GITHUB_REF" == refs/tags/* ]]; then + expected_release="${GITHUB_REF#refs/tags/}" + if [[ ! "$expected_release" =~ ^v[0-9]+([.][0-9]+){1,3}([-.][0-9A-Za-z][0-9A-Za-z.-]*)?$ ]]; then + echo "ERROR: release tag is not a supported version identity: $expected_release" >&2 + exit 1 + fi + elif [ "$GITHUB_REF" != "refs/heads/main" ]; then + echo "ERROR: managed images may only be promoted from main or a v* release tag." >&2 + exit 1 + fi + + expected_artifacts=( + managed-image-candidate-openclaw-linux-amd64 + managed-image-candidate-openclaw-linux-arm64 + managed-image-candidate-hermes-linux-amd64 + managed-image-candidate-hermes-linux-arm64 + managed-image-candidate-langchain-deepagents-code-linux-amd64 + managed-image-candidate-langchain-deepagents-code-linux-arm64 + ) + expected_agents=( + openclaw + openclaw + hermes + hermes + langchain-deepagents-code + langchain-deepagents-code + ) + expected_platforms=( + linux/amd64 + linux/arm64 + linux/amd64 + linux/arm64 + linux/amd64 + linux/arm64 + ) + if [ ! -d "$CANDIDATE_ROOT" ] || [ -L "$CANDIDATE_ROOT" ]; then + echo "ERROR: managed image candidate root is missing or unsafe." >&2 + exit 1 + fi + actual_entry_count="$( + find "$CANDIDATE_ROOT" ! -path "$CANDIDATE_ROOT" -prune -print | + wc -l | tr -d '[:space:]' + )" + if [ "$actual_entry_count" != "${#expected_artifacts[@]}" ]; then + echo "ERROR: expected exactly six managed image candidate artifacts." >&2 + exit 1 + fi + + candidate_files=() + for index in "${!expected_artifacts[@]}"; do + artifact="${expected_artifacts[$index]}" + expected_agent="${expected_agents[$index]}" + expected_platform="${expected_platforms[$index]}" + artifact_dir="$CANDIDATE_ROOT/$artifact" + contract="$artifact_dir/contract.json" + if [ ! -d "$artifact_dir" ] || [ -L "$artifact_dir" ] || + [ ! -f "$contract" ] || [ -L "$contract" ]; then + echo "ERROR: required managed image candidate is missing or unsafe: $artifact" >&2 + exit 1 + fi + artifact_entry_count="$( + find "$artifact_dir" ! -path "$artifact_dir" -prune -print | + wc -l | tr -d '[:space:]' + )" + if [ "$artifact_entry_count" != "1" ] || + ! jq -e \ + --arg agent "$expected_agent" \ + --arg platform "$expected_platform" \ + '.agent == $agent and .platform == $platform' \ + "$contract" >/dev/null; then + echo "ERROR: managed image candidate artifact identity is invalid: $artifact" >&2 + exit 1 + fi + candidate_files+=("$contract") + done + + candidate_set="$RUNNER_TEMP/managed-image-candidate-set.json" + jq -s 'sort_by(.agent, .platform)' "${candidate_files[@]}" > "$candidate_set" + if ! jq -e \ + --arg ref "$GITHUB_REF" \ + --arg release "$expected_release" \ + --arg repository "$GITHUB_REPOSITORY" \ + --arg revision "$GITHUB_SHA" \ + --arg cohort "$expected_cohort" \ + --argjson runAttempt "$GITHUB_RUN_ATTEMPT" \ + --argjson runId "$GITHUB_RUN_ID" \ + ' + length == 6 + and ([.[].agent] | group_by(.) | map({key: .[0], value: length}) | from_entries) == { + "hermes": 2, + "langchain-deepagents-code": 2, + "openclaw": 2 + } + and (group_by(.agent) | all(.[]; + ([.[].platform] | sort) == ["linux/amd64", "linux/arm64"] + )) + and all(.[]; + (keys | sort) == [ + "agent", + "attestations", + "baseReference", + "contractVersion", + "digest", + "image", + "phase", + "platform", + "reference", + "release", + "run", + "source" + ] + and .contractVersion == 1 + and .phase == "candidate" + and (.digest | test("^sha256:[0-9a-f]{64}$")) + and .reference == (.image + "@" + .digest) + and (.baseReference | test("@sha256:[0-9a-f]{64}$")) + and .attestations == {provenance: "mode=max", sbom: true} + and .source == { + repository: $repository, + revision: $revision, + ref: $ref, + cohort: $cohort + } + and .run == {id: $runId, attempt: $runAttempt} + and .release == (if $release == "" then null else $release end) + and (has("aliases") | not) + ) + and ([.[].reference] | unique | length) == 6 + and ([.[].baseReference] | unique | length) == 6 + and ( + map({key: (.agent + "|" + .platform), value: .image}) | from_entries + ) == { + "openclaw|linux/amd64": "ghcr.io/nvidia/nemoclaw/openclaw-sandbox", + "openclaw|linux/arm64": "ghcr.io/nvidia/nemoclaw/openclaw-sandbox", + "hermes|linux/amd64": "ghcr.io/nvidia/nemoclaw/hermes-sandbox", + "hermes|linux/arm64": "ghcr.io/nvidia/nemoclaw/hermes-sandbox", + "langchain-deepagents-code|linux/amd64": + "ghcr.io/nvidia/nemoclaw/langchain-deepagents-code-sandbox", + "langchain-deepagents-code|linux/arm64": + "ghcr.io/nvidia/nemoclaw/langchain-deepagents-code-sandbox" + } + ' "$candidate_set" >/dev/null + then + echo "ERROR: complete managed image candidate set failed closed validation." >&2 + exit 1 + fi + printf 'candidate_set=%s\n' "$candidate_set" >> "$GITHUB_OUTPUT" + + - name: Verify release tag before managed image promotion + if: startsWith(github.ref, 'refs/tags/v') + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 + env: + RELEASE_TAG: ${{ github.ref_name }} + RELEASE_REVISION: ${{ github.sha }} + with: + script: | + const releaseTag = process.env.RELEASE_TAG ?? ''; + const releaseRevision = process.env.RELEASE_REVISION ?? ''; + if (!/^v\d+\.\d+\.\d+$/.test(releaseTag)) { + throw new Error(`Refusing to verify non-semver tag: ${releaseTag}`); + } + if (!/^[0-9a-f]{40}$/.test(releaseRevision)) { + throw new Error(`Refusing release promotion with invalid revision: ${releaseRevision}`); + } + const { owner, repo } = context.repo; + const ref = await github.rest.git.getRef({ owner, repo, ref: `tags/${releaseTag}` }); + if (ref.data.object.type !== 'tag') { + throw new Error(`Release tag ${releaseTag} must be annotated`); + } + const tagObjectSha = ref.data.object.sha; + let tagObject; + for (let attempt = 1; attempt <= 10; attempt += 1) { + ({ data: tagObject } = await github.rest.git.getTag({ + owner, + repo, + tag_sha: tagObjectSha, + })); + if (tagObject.verification?.verified === true) break; + if (attempt < 10) { + core.info(`Waiting for GitHub tag verification (${attempt}/10)`); + await new Promise((resolve) => setTimeout(resolve, 3000)); + } + } + if ( + tagObject.tag !== releaseTag || + tagObject.object.type !== 'commit' || + tagObject.object.sha !== releaseRevision || + tagObject.verification?.verified !== true + ) { + throw new Error(`Release tag ${releaseTag} is not a verified direct commit tag`); + } + + - name: Set up Docker Buildx for promotion + uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4.2.0 + + - name: Log in to GHCR for promotion + uses: docker/login-action@af1e73f918a031802d376d3c8bbc3fe56130a9b0 # v4.4.0 + with: + registry: ${{ env.REGISTRY }} + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Promote validated multi-platform managed image cohort + shell: bash + env: + CANDIDATE_SET: ${{ steps.candidates.outputs.candidate_set }} + run: | + set -euo pipefail + contract_root="$RUNNER_TEMP/managed-image-contracts" + mkdir -p "$contract_root" + cohort="ghrun-${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}" + if [[ ! "$cohort" =~ ^ghrun-[1-9][0-9]{0,19}-[1-9][0-9]{0,9}$ ]]; then + echo "ERROR: publication cohort is invalid: $cohort" >&2 + exit 1 + fi + + # Resolve all six immutable candidates before the first alias write. + while IFS= read -r candidate; do + reference="$(jq -r '.reference' <<<"$candidate")" + docker buildx imagetools inspect "$reference" >/dev/null + done < <(jq -c '.[]' "$CANDIDATE_SET") + + manifests="$RUNNER_TEMP/managed-image-cohort-manifests.jsonl" + : > "$manifests" + for agent in openclaw hermes langchain-deepagents-code; do + agent_candidates="$RUNNER_TEMP/managed-image-${agent}-candidates.json" + jq -ce --arg agent "$agent" \ + '[.[] | select(.agent == $agent)] | sort_by(.platform)' \ + "$CANDIDATE_SET" > "$agent_candidates" + image="$(jq -er '.[0].image' "$agent_candidates")" + mapfile -t sources < <(jq -r '.[].reference' "$agent_candidates") + if [ "${#sources[@]}" -ne 2 ]; then + echo "ERROR: expected exactly two platform candidates for $agent." >&2 + exit 1 + fi + cohort_alias="${image}:cohort-${cohort}" + docker buildx imagetools create --tag "$cohort_alias" "${sources[@]}" + + cohort_raw="$RUNNER_TEMP/managed-image-${agent}-cohort.raw" + docker buildx imagetools inspect "$cohort_alias" --raw > "$cohort_raw" + if ! jq -e ' + ( + .mediaType == "application/vnd.oci.image.index.v1+json" or + .mediaType == "application/vnd.docker.distribution.manifest.list.v2+json" + ) + and ( + [ + .manifests[] + | select( + .platform.os == "linux" and + ( + .platform.architecture == "amd64" or + .platform.architecture == "arm64" + ) + ) + | "linux/" + .platform.architecture + ] + | sort + ) == ["linux/amd64", "linux/arm64"] + ' "$cohort_raw" >/dev/null; then + echo "ERROR: staged cohort alias is not exactly linux/amd64 plus linux/arm64: $cohort_alias" >&2 + exit 1 + fi + cohort_inspect="$(docker buildx imagetools inspect "$cohort_alias")" + mapfile -t cohort_digests < <( + printf '%s\n' "$cohort_inspect" | + sed -nE 's/^Digest:[[:space:]]*(sha256:[0-9a-f]{64})$/\1/p' + ) + if [ "${#cohort_digests[@]}" -ne 1 ]; then + echo "ERROR: staged cohort alias did not resolve to one exact digest: $cohort_alias" >&2 + exit 1 + fi + cohort_digest="${cohort_digests[0]}" + cohort_reference="${image}@${cohort_digest}" + exact_raw="$RUNNER_TEMP/managed-image-${agent}-cohort-exact.raw" + docker buildx imagetools inspect "$cohort_reference" --raw > "$exact_raw" + if ! cmp -s "$cohort_raw" "$exact_raw"; then + echo "ERROR: staged cohort alias bytes do not match their exact digest: $cohort_alias" >&2 + exit 1 + fi + jq -cn \ + --arg agent "$agent" \ + --arg alias "$cohort_alias" \ + --arg digest "$cohort_digest" \ + --arg image "$image" \ + --arg reference "$cohort_reference" \ + --slurpfile platforms "$agent_candidates" \ + '{ + agent: $agent, + image: $image, + digest: $digest, + reference: $reference, + alias: $alias, + platforms: ($platforms[0] | map({ + key: .platform, + value: { + digest, + reference, + baseReference, + attestations + } + }) | from_entries) + }' >> "$manifests" + done + + cohort_manifests="$RUNNER_TEMP/managed-image-cohort-manifests.json" + jq -s 'sort_by(.agent)' "$manifests" > "$cohort_manifests" + if ! jq -e ' + length == 3 + and ([.[].agent] | sort) == [ + "hermes", + "langchain-deepagents-code", + "openclaw" + ] + and all(.[]; + (.digest | test("^sha256:[0-9a-f]{64}$")) + and .reference == (.image + "@" + .digest) + and (.platforms | keys | sort) == ["linux/amd64", "linux/arm64"] + and all(.platforms[]; + (.digest | test("^sha256:[0-9a-f]{64}$")) + and (.baseReference | test("@sha256:[0-9a-f]{64}$")) + and .attestations == {provenance: "mode=max", sbom: true} + ) + ) + ' "$cohort_manifests" >/dev/null; then + echo "ERROR: staged multi-platform cohort failed exact validation." >&2 + exit 1 + fi + + # Prove that every staged agent alias is anonymously pullable on both + # supported architectures before the single consumer pointer moves. + anonymous_config="$(mktemp -d "$RUNNER_TEMP/anonymous-cohort-docker-XXXXXX")" + chmod 0700 "$anonymous_config" + while IFS= read -r manifest; do + cohort_reference="$(jq -r '.reference' <<<"$manifest")" + for platform in linux/amd64 linux/arm64; do + DOCKER_CONFIG="$anonymous_config" docker pull \ + --platform "$platform" \ + "$cohort_reference" + done + done < <(jq -c '.[]' "$cohort_manifests") + + # The OpenClaw revision/release alias is the sole consumer-visible + # root pointer. Hermes and DCode are discovered through the already + # validated cohort aliases, making the all-agent handoff atomic. + openclaw_manifest="$(jq -ce '.[] | select(.agent == "openclaw")' "$cohort_manifests")" + openclaw_alias="$(jq -r '.alias' <<<"$openclaw_manifest")" + consumer_aliases=("$(jq -r '.image' <<<"$openclaw_manifest"):${GITHUB_SHA}") + release_tag="$(jq -r '.[0].release // empty' "$CANDIDATE_SET")" + if [ -n "$release_tag" ]; then + consumer_aliases+=("$(jq -r '.image' <<<"$openclaw_manifest"):${release_tag}") + fi + consumer_tag_args=() + for alias in "${consumer_aliases[@]}"; do + consumer_tag_args+=(--tag "$alias") + done + docker buildx imagetools create "${consumer_tag_args[@]}" "$openclaw_alias" + + openclaw_raw="$RUNNER_TEMP/managed-image-openclaw-cohort.raw" + docker buildx imagetools inspect "$openclaw_alias" --raw > "$openclaw_raw" + for alias in "${consumer_aliases[@]}"; do + alias_raw="$RUNNER_TEMP/managed-image-openclaw-pointer-${alias##*:}.raw" + docker buildx imagetools inspect "$alias" --raw > "$alias_raw" + if ! cmp -s "$openclaw_raw" "$alias_raw"; then + echo "ERROR: OpenClaw cohort pointer is not exact: $alias" >&2 + exit 1 + fi + done + + while IFS= read -r candidate; do + agent="$(jq -r '.agent' <<<"$candidate")" + platform="$(jq -r '.platform' <<<"$candidate")" + artifact_platform="${platform//\//-}" + cohort_manifest="$(jq -ce --arg agent "$agent" '.[] | select(.agent == $agent)' "$cohort_manifests")" + cohort_alias="$(jq -r '.alias' <<<"$cohort_manifest")" + aliases=("$cohort_alias") + if [ "$agent" = "openclaw" ]; then + aliases+=("${consumer_aliases[@]}") + fi + aliases_json="$(printf '%s\n' "${aliases[@]}" | jq -R . | jq -s .)" + contract_dir="$contract_root/$agent/$artifact_platform" + mkdir -p "$contract_dir" + jq \ + --argjson aliases "$aliases_json" \ + '{ + contractVersion, + agent, + image, + digest, + reference, + baseReference, + platform, + attestations, + source: { + repository: .source.repository, + revision: .source.revision, + release, + cohort: .source.cohort + }, + run, + aliases: $aliases + }' <<<"$candidate" > "$contract_dir/contract.json" + if ! jq -e \ + --arg agent "$agent" \ + --arg platform "$platform" \ + --arg cohort "$cohort" \ + --argjson aliases "$aliases_json" \ + '(keys | sort) == [ + "agent", + "aliases", + "attestations", + "baseReference", + "contractVersion", + "digest", + "image", + "platform", + "reference", + "run", + "source" + ] + and .contractVersion == 1 + and .agent == $agent + and .platform == $platform + and (.digest | test("^sha256:[0-9a-f]{64}$")) + and .reference == (.image + "@" + .digest) + and (.baseReference | test("@sha256:[0-9a-f]{64}$")) + and .attestations == {provenance: "mode=max", sbom: true} + and .source.revision == $ENV.GITHUB_SHA + and .source.cohort == $cohort + and .run == { + id: ($ENV.GITHUB_RUN_ID | tonumber), + attempt: ($ENV.GITHUB_RUN_ATTEMPT | tonumber) + } + and .aliases == $aliases + and ( + (.image + ":cohort-" + $cohort) as $cohortAlias + | (.aliases | index($cohortAlias)) != null + )' "$contract_dir/contract.json" >/dev/null; then + echo "ERROR: final exact platform contract failed validation: $agent/$platform" >&2 + exit 1 + fi + done < <(jq -c '.[]' "$CANDIDATE_SET") + + cohort_contract="$contract_root/cohort.json" + jq -n \ + --arg repository "$GITHUB_REPOSITORY" \ + --arg revision "$GITHUB_SHA" \ + --arg release "$release_tag" \ + --arg cohort "$cohort" \ + --argjson runAttempt "$GITHUB_RUN_ATTEMPT" \ + --argjson runId "$GITHUB_RUN_ID" \ + --slurpfile agents "$cohort_manifests" \ + '{ + contractVersion: 1, + cohort: $cohort, + source: { + repository: $repository, + revision: $revision, + release: (if $release == "" then null else $release end) + }, + run: {id: $runId, attempt: $runAttempt}, + platforms: ["linux/amd64", "linux/arm64"], + agents: ($agents[0] | map({key: .agent, value: del(.agent)}) | from_entries) + }' > "$cohort_contract" + jq -e \ + --arg cohort "$cohort" \ + --arg revision "$GITHUB_SHA" \ + --argjson runAttempt "$GITHUB_RUN_ATTEMPT" \ + --argjson runId "$GITHUB_RUN_ID" \ + '.contractVersion == 1 + and .cohort == $cohort + and .source.revision == $revision + and .run == {id: $runId, attempt: $runAttempt} + and .platforms == ["linux/amd64", "linux/arm64"] + and (.agents | keys | sort) == [ + "hermes", + "langchain-deepagents-code", + "openclaw" + ] + and all(.agents[]; + (.digest | test("^sha256:[0-9a-f]{64}$")) + and .reference == (.image + "@" + .digest) + and (.platforms | keys | sort) == ["linux/amd64", "linux/arm64"] + )' "$cohort_contract" >/dev/null + + - name: Upload complete managed image cohort contract + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: managed-image-cohort + path: ${{ runner.temp }}/managed-image-contracts/cohort.json + if-no-files-found: error + retention-days: 90 + + - name: Upload OpenClaw amd64 managed image contract + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: managed-image-openclaw-linux-amd64 + path: ${{ runner.temp }}/managed-image-contracts/openclaw/linux-amd64/contract.json + if-no-files-found: error + retention-days: 90 + + - name: Upload OpenClaw arm64 managed image contract + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: managed-image-openclaw-linux-arm64 + path: ${{ runner.temp }}/managed-image-contracts/openclaw/linux-arm64/contract.json + if-no-files-found: error + retention-days: 90 + + - name: Upload Hermes amd64 managed image contract + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: managed-image-hermes-linux-amd64 + path: ${{ runner.temp }}/managed-image-contracts/hermes/linux-amd64/contract.json + if-no-files-found: error + retention-days: 90 + + - name: Upload Hermes arm64 managed image contract + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: managed-image-hermes-linux-arm64 + path: ${{ runner.temp }}/managed-image-contracts/hermes/linux-arm64/contract.json + if-no-files-found: error + retention-days: 90 + + - name: Upload Deep Agents Code amd64 managed image contract + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: managed-image-langchain-deepagents-code-linux-amd64 + path: ${{ runner.temp }}/managed-image-contracts/langchain-deepagents-code/linux-amd64/contract.json + if-no-files-found: error + retention-days: 90 + + - name: Upload Deep Agents Code arm64 managed image contract uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: - name: managed-image-${{ matrix.agent }}-${{ matrix.artifact_platform }} - path: ${{ runner.temp }}/managed-image-contract/contract.json + name: managed-image-langchain-deepagents-code-linux-arm64 + path: ${{ runner.temp }}/managed-image-contracts/langchain-deepagents-code/linux-arm64/contract.json if-no-files-found: error retention-days: 90 diff --git a/test/helpers/managed-image-publication-barrier.ts b/test/helpers/managed-image-publication-barrier.ts new file mode 100644 index 0000000000..550c016c03 --- /dev/null +++ b/test/helpers/managed-image-publication-barrier.ts @@ -0,0 +1,228 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { spawnSync } from "node:child_process"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; + +export const publicationAgents = ["openclaw", "hermes", "langchain-deepagents-code"] as const; +export const publicationPlatforms = ["linux/amd64", "linux/arm64"] as const; + +const revision = "a".repeat(40); +const repository = "NVIDIA/NemoClaw"; +const runId = "7744"; +const runAttempt = "2"; +const cohort = `ghrun-${runId}-${runAttempt}`; + +type Candidate = { + agent: (typeof publicationAgents)[number]; + platform: (typeof publicationPlatforms)[number]; + contract: Record; + artifact: string; +}; + +export type CandidateMutation = (candidates: Candidate[]) => Candidate[]; + +type PromotionResult = { + calls: string[]; + cohortContract: Record | null; + platformContracts: Record>; + status: number | null; + stderr: string; +}; + +function imageFor(agent: (typeof publicationAgents)[number]): string { + return `ghcr.io/nvidia/nemoclaw/${agent}-sandbox`; +} + +function digestFor(agentIndex: number, platformIndex: number, base: boolean): string { + const offset = base ? 20 : 1; + return `sha256:${(offset + agentIndex * 2 + platformIndex).toString(16).padStart(64, "0")}`; +} + +function candidates(): Candidate[] { + return publicationAgents.flatMap((agent, agentIndex) => + publicationPlatforms.map((platform, platformIndex) => { + const image = imageFor(agent); + const digest = digestFor(agentIndex, platformIndex, false); + const baseDigest = digestFor(agentIndex, platformIndex, true); + return { + agent, + platform, + artifact: `managed-image-candidate-${agent}-${platform.replace("/", "-")}`, + contract: { + contractVersion: 1, + phase: "candidate", + agent, + image, + digest, + reference: `${image}@${digest}`, + baseReference: `ghcr.io/nvidia/nemoclaw/${agent}-sandbox-base@${baseDigest}`, + platform, + attestations: { provenance: "mode=max", sbom: true }, + source: { + repository, + revision, + ref: "refs/heads/main", + cohort, + }, + run: { id: Number(runId), attempt: Number(runAttempt) }, + release: null, + }, + }; + }), + ); +} + +export function runPublicationBarrier( + script: string, + mutate: CandidateMutation = (value) => value, + afterBarrier = "", +): { + dockerCalls: string[]; + status: number | null; + stderr: string; + stdout: string; +} { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-managed-candidates-")); + const candidateRoot = path.join(root, "candidates"); + const output = path.join(root, "github-output"); + const dockerCalls = path.join(root, "docker-calls"); + const bin = path.join(root, "bin"); + fs.mkdirSync(candidateRoot); + fs.mkdirSync(bin); + fs.writeFileSync( + path.join(bin, "docker"), + '#!/usr/bin/env bash\nprintf \'%s\\n\' "$*" >> "$DOCKER_CALLS"\nexit 97\n', + ); + fs.chmodSync(path.join(bin, "docker"), 0o755); + + try { + for (const candidate of mutate(candidates())) { + const artifactDir = path.join(candidateRoot, candidate.artifact); + fs.mkdirSync(artifactDir); + fs.writeFileSync( + path.join(artifactDir, "contract.json"), + `${JSON.stringify(candidate.contract)}\n`, + ); + } + const result = spawnSync("bash", ["-c", `${script}\n${afterBarrier}`], { + cwd: root, + encoding: "utf8", + env: { + ...process.env, + CANDIDATE_ROOT: candidateRoot, + DOCKER_CALLS: dockerCalls, + GITHUB_OUTPUT: output, + GITHUB_REF: "refs/heads/main", + GITHUB_REPOSITORY: repository, + GITHUB_RUN_ATTEMPT: runAttempt, + GITHUB_RUN_ID: runId, + GITHUB_SHA: revision, + PATH: `${bin}:${process.env.PATH ?? ""}`, + RUNNER_TEMP: root, + }, + }); + return { + dockerCalls: fs.existsSync(dockerCalls) + ? fs.readFileSync(dockerCalls, "utf8").split(/\r?\n/u).filter(Boolean) + : [], + status: result.status, + stderr: result.stderr, + stdout: result.stdout, + }; + } finally { + fs.rmSync(root, { recursive: true, force: true }); + } +} + +export function runManagedImagePromotion(script: string, failCohortAgent = ""): PromotionResult { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-managed-promotion-")); + const bin = path.join(root, "bin"); + const calls = path.join(root, "docker-calls"); + const candidateSet = path.join(root, "candidate-set.json"); + const contracts = path.join(root, "managed-image-contracts"); + const digest = `sha256:${"f".repeat(64)}`; + const raw = JSON.stringify({ + mediaType: "application/vnd.oci.image.index.v1+json", + manifests: [ + { + digest: `sha256:${"1".repeat(64)}`, + platform: { os: "linux", architecture: "amd64" }, + }, + { + digest: `sha256:${"2".repeat(64)}`, + platform: { os: "linux", architecture: "arm64" }, + }, + ], + }); + fs.mkdirSync(bin); + fs.writeFileSync( + path.join(bin, "docker"), + `#!/usr/bin/env bash +set -euo pipefail +printf '%s\\n' "$*" >> "$DOCKER_CALLS" +if [ -n "\${FAIL_COHORT_AGENT:-}" ] && + [[ "$*" == *"imagetools create"* ]] && + [[ "$*" == *"/\${FAIL_COHORT_AGENT}-sandbox:cohort-"* ]]; then + exit 91 +fi +if [[ "$*" == *"imagetools inspect"* ]] && [[ "$*" == *"--raw"* ]]; then + printf '%s' '${raw}' +elif [[ "$*" == *"imagetools inspect"* ]]; then + printf 'Name: fake\\nMediaType: application/vnd.oci.image.index.v1+json\\nDigest: ${digest}\\n' +fi +`, + ); + fs.chmodSync(path.join(bin, "docker"), 0o755); + fs.writeFileSync( + candidateSet, + `${JSON.stringify(candidates().map(({ contract }) => contract))}\n`, + ); + + try { + const result = spawnSync("bash", ["-c", script], { + cwd: root, + encoding: "utf8", + env: { + ...process.env, + CANDIDATE_SET: candidateSet, + DOCKER_CALLS: calls, + FAIL_COHORT_AGENT: failCohortAgent, + GITHUB_REPOSITORY: repository, + GITHUB_RUN_ATTEMPT: runAttempt, + GITHUB_RUN_ID: runId, + GITHUB_SHA: revision, + PATH: `${bin}:${process.env.PATH ?? ""}`, + RUNNER_TEMP: root, + }, + }); + const platformContracts: Record> = {}; + for (const agent of publicationAgents) { + for (const platform of publicationPlatforms) { + const artifactPlatform = platform.replace("/", "-"); + const contract = path.join(contracts, agent, artifactPlatform, "contract.json"); + if (fs.existsSync(contract)) { + platformContracts[`${agent}|${platform}`] = JSON.parse( + fs.readFileSync(contract, "utf8"), + ) as Record; + } + } + } + const cohortContract = path.join(contracts, "cohort.json"); + return { + calls: fs.existsSync(calls) + ? fs.readFileSync(calls, "utf8").split(/\r?\n/u).filter(Boolean) + : [], + cohortContract: fs.existsSync(cohortContract) + ? (JSON.parse(fs.readFileSync(cohortContract, "utf8")) as Record) + : null, + platformContracts, + status: result.status, + stderr: result.stderr, + }; + } finally { + fs.rmSync(root, { recursive: true, force: true }); + } +} diff --git a/test/managed-image-publication-workflow.test.ts b/test/managed-image-publication-workflow.test.ts index 82b3128c4c..50ec2f69ea 100644 --- a/test/managed-image-publication-workflow.test.ts +++ b/test/managed-image-publication-workflow.test.ts @@ -7,6 +7,13 @@ import path from "node:path"; import { describe, expect, it } from "vitest"; import YAML from "yaml"; +import { + publicationAgents, + publicationPlatforms, + runManagedImagePromotion, + runPublicationBarrier, +} from "./helpers/managed-image-publication-barrier"; + type Step = { env?: Record; id?: string; @@ -18,6 +25,7 @@ type Step = { type MatrixEntry = { agent?: string; + arch?: string; artifact_platform?: string; base_image?: string; display_name?: string; @@ -25,6 +33,7 @@ type MatrixEntry = { image?: string; platform?: string; required_binary?: string; + runner?: string; }; type Job = { @@ -104,6 +113,13 @@ function managedPublisher(workflow: Workflow): Job { ); } +function managedPromoter(workflow: Workflow): Job { + return required( + workflow.jobs?.promote, + "managed-image workflow is missing its aggregate promoter", + ); +} + function publicationBoundaryErrors(baseWorkflow: Workflow, managedWorkflow: Workflow): string[] { const triggerPaths = baseWorkflow.on?.push?.paths ?? []; const caller = required( @@ -111,11 +127,13 @@ function publicationBoundaryErrors(baseWorkflow: Workflow, managedWorkflow: Work "base-image workflow is missing the managed-image publisher", ); const publisher = managedPublisher(managedWorkflow); + const promoter = managedPromoter(managedWorkflow); const steps = publisher.steps ?? []; const build = step(publisher, "Build and push managed image by digest"); const base = step(publisher, "Validate exact base image contract"); const validate = step(publisher, "Validate exact managed image"); const workflowSource = JSON.stringify(managedWorkflow); + const publisherSource = JSON.stringify(publisher); const validationMarkers = [ 'mktemp -d "$RUNNER_TEMP/anonymous-docker-XXXXXX"', 'DOCKER_CONFIG="$anonymous_config" docker pull --platform "$PLATFORM" "$reference"', @@ -132,7 +150,6 @@ function publicationBoundaryErrors(baseWorkflow: Workflow, managedWorkflow: Work ]; const forbiddenPerLanePromotionMarkers = [ 'aliases=("${IMAGE}:${GITHUB_SHA}")', - 'release_tag="${GITHUB_REF#refs/tags/}"', "docker buildx imagetools create", "docker tag ", "docker push ", @@ -172,11 +189,14 @@ function publicationBoundaryErrors(baseWorkflow: Workflow, managedWorkflow: Work .filter((marker) => !validate.run?.includes(marker)) .map((marker) => `exact managed image validation is missing ${marker}`), ...forbiddenPerLanePromotionMarkers - .filter((marker) => workflowSource.includes(marker)) + .filter((marker) => publisherSource.includes(marker)) .map((marker) => `per-agent lane must not publish mutable alias with ${marker}`), ...(buildIndex >= 0 && buildIndex < validateIndex ? [] : ["managed image validation must follow its immutable digest build"]), + ...(promoter.needs === "build-and-validate" + ? [] + : ["aggregate promotion must require every matrix lane"]), ]; } @@ -207,9 +227,13 @@ describe("complete managed-image publication workflow", () => { "base-image workflow is missing Hermes and DCode publishers", ); expect(step(qemuPublisher, "Build and push").id).toBe("build"); + expect(step(qemuPublisher, "Build and push").with?.platforms).toBe("linux/amd64,linux/arm64"); expect(step(qemuPublisher, "Export managed base image contract").run).toContain( 'reference="${IMAGE}@${DIGEST}"', ); + expect(step(qemuPublisher, "Export managed base image contract").run).toContain( + "platformReferences: $platformReferences", + ); expect(step(qemuPublisher, "Upload managed base image contract").with?.name).toBe( "managed-base-${{ matrix.agent }}", ); @@ -224,12 +248,34 @@ describe("complete managed-image publication workflow", () => { expect(step(openClawPublisher, "Export managed base image contract").env?.DIGEST).toBe( "${{ steps.manifest.outputs.digest }}", ); + expect(step(openClawPublisher, "Export managed base image contract").run).toContain( + "platformDigests: $platformDigests", + ); expect(step(openClawPublisher, "Upload managed base image contract").with?.name).toBe( "managed-base-openclaw", ); + + const nativeOpenClaw = required( + baseWorkflow.jobs?.["build-openclaw-platforms"], + "base-image workflow is missing native OpenClaw platforms", + ); + expect(nativeOpenClaw.strategy?.matrix?.include).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + arch: "amd64", + platform: "linux/amd64", + runner: "ubuntu-24.04", + }), + expect.objectContaining({ + arch: "arm64", + platform: "linux/arm64", + runner: "ubuntu-24.04-arm", + }), + ]), + ); }); - it("publishes every complete agent image for the initial Linux x86_64 contract (#7744)", () => { + it("publishes an exact native amd64 and arm64 lane for every shipped agent (#7744)", () => { const workflow = readWorkflow("managed-images.yaml"); const publisher = managedPublisher(workflow); @@ -238,12 +284,13 @@ describe("complete managed-image publication workflow", () => { contents: "read", packages: "write", }); - expect(publisher["runs-on"]).toBe("ubuntu-24.04"); - expect(publisher["timeout-minutes"]).toBe(90); + expect(publisher["runs-on"]).toBe("${{ matrix.runner }}"); + expect(publisher["timeout-minutes"]).toBe(120); expect(publisher.strategy?.["fail-fast"]).toBe(false); expect(publisher.strategy?.matrix?.include).toEqual([ { agent: "openclaw", + arch: "amd64", display_name: "OpenClaw", dockerfile: "Dockerfile", base_image: "nvidia/nemoclaw/sandbox-base", @@ -251,9 +298,23 @@ describe("complete managed-image publication workflow", () => { platform: "linux/amd64", artifact_platform: "linux-amd64", required_binary: "/usr/local/bin/openclaw", + runner: "ubuntu-24.04", + }, + { + agent: "openclaw", + arch: "arm64", + display_name: "OpenClaw", + dockerfile: "Dockerfile", + base_image: "nvidia/nemoclaw/sandbox-base", + image: "nvidia/nemoclaw/openclaw-sandbox", + platform: "linux/arm64", + artifact_platform: "linux-arm64", + required_binary: "/usr/local/bin/openclaw", + runner: "ubuntu-24.04-arm", }, { agent: "hermes", + arch: "amd64", display_name: "Hermes", dockerfile: "agents/hermes/Dockerfile", base_image: "nvidia/nemoclaw/hermes-sandbox-base", @@ -261,9 +322,23 @@ describe("complete managed-image publication workflow", () => { platform: "linux/amd64", artifact_platform: "linux-amd64", required_binary: "/usr/local/bin/hermes", + runner: "ubuntu-24.04", + }, + { + agent: "hermes", + arch: "arm64", + display_name: "Hermes", + dockerfile: "agents/hermes/Dockerfile", + base_image: "nvidia/nemoclaw/hermes-sandbox-base", + image: "nvidia/nemoclaw/hermes-sandbox", + platform: "linux/arm64", + artifact_platform: "linux-arm64", + required_binary: "/usr/local/bin/hermes", + runner: "ubuntu-24.04-arm", }, { agent: "langchain-deepagents-code", + arch: "amd64", display_name: "Deep Agents Code", dockerfile: "agents/langchain-deepagents-code/Dockerfile", base_image: "nvidia/nemoclaw/langchain-deepagents-code-sandbox-base", @@ -271,16 +346,39 @@ describe("complete managed-image publication workflow", () => { platform: "linux/amd64", artifact_platform: "linux-amd64", required_binary: "/usr/local/bin/dcode", + runner: "ubuntu-24.04", + }, + { + agent: "langchain-deepagents-code", + arch: "arm64", + display_name: "Deep Agents Code", + dockerfile: "agents/langchain-deepagents-code/Dockerfile", + base_image: "nvidia/nemoclaw/langchain-deepagents-code-sandbox-base", + image: "nvidia/nemoclaw/langchain-deepagents-code-sandbox", + platform: "linux/arm64", + artifact_platform: "linux-arm64", + required_binary: "/usr/local/bin/dcode", + runner: "ubuntu-24.04-arm", }, ]); + expect( + publisher.strategy?.matrix?.include?.map(({ agent, platform }) => `${agent}|${platform}`), + ).toEqual( + publicationAgents.flatMap((agent) => + publicationPlatforms.map((platform) => `${agent}|${platform}`), + ), + ); }); it("pins actions, validates exact digests, and records the immutable image contract (#7744)", () => { const workflow = readWorkflow("managed-images.yaml"); const publisher = managedPublisher(workflow); + const promoter = managedPromoter(workflow); const steps = publisher.steps ?? []; - for (const action of steps.filter((candidate) => candidate.uses)) { + for (const action of [...steps, ...(promoter.steps ?? [])].filter( + (candidate) => candidate.uses, + )) { expect(action.uses, action.name).toMatch(fullShaAction); } expect(step(publisher, "Checkout").with?.["persist-credentials"]).toBe(false); @@ -306,24 +404,36 @@ describe("complete managed-image publication workflow", () => { expect(build.with?.tags).toBeUndefined(); expect(build.with?.labels).toContain("org.opencontainers.image.revision=${{ github.sha }}"); expect(build.with?.labels).toContain("io.nvidia.nemoclaw.managed-image.contract=1"); + expect(build.with?.labels).toContain( + "io.nvidia.nemoclaw.managed-image.cohort=ghrun-${{ github.run_id }}-${{ github.run_attempt }}", + ); - const contract = step(publisher, "Export managed image contract"); + const base = step(publisher, "Validate exact base image contract"); + expect(base.run).toContain(".platformReferences[$platform]"); + expect(base.run).toContain('imagetools inspect "$platform_reference"'); + + const contract = step(publisher, "Export validated managed image candidate"); for (const marker of [ "--arg baseReference", "--arg digest", "--arg platform", + "--arg cohort", "--arg revision", "--argjson runAttempt", "--argjson runId", "contractVersion: 1", + 'phase: "candidate"', + "attestations: {", + 'provenance: "mode=max"', + "sbom: true", ]) { expect(contract.run).toContain(marker); } - expect(step(publisher, "Upload managed image contract").with).toMatchObject({ - name: "managed-image-${{ matrix.agent }}-${{ matrix.artifact_platform }}", - path: "${{ runner.temp }}/managed-image-contract/contract.json", + expect(step(publisher, "Upload validated managed image candidate").with).toMatchObject({ + name: "managed-image-candidate-${{ matrix.agent }}-${{ matrix.artifact_platform }}", + path: "${{ runner.temp }}/managed-image-candidate/contract.json", "if-no-files-found": "error", - "retention-days": 90, + "retention-days": 1, }); }); @@ -332,17 +442,214 @@ describe("complete managed-image publication workflow", () => { const publisher = managedPublisher(workflow); const steps = publisher.steps ?? []; const source = steps.map((candidate) => candidate.run ?? "").join("\n"); - const contract = step(publisher, "Export managed image contract"); + const contract = step(publisher, "Export validated managed image candidate"); - expect(publisher.strategy?.matrix?.include).toHaveLength(3); + expect(publisher.strategy?.matrix?.include).toHaveLength(6); expect(steps.map((candidate) => candidate.name)).not.toContain( "Promote validated managed image aliases", ); expect(source).not.toContain('aliases=("${IMAGE}:${GITHUB_SHA}")'); - expect(source).not.toContain('release_tag="${GITHUB_REF#refs/tags/}"'); expect(source).not.toContain("docker buildx imagetools create"); expect(source).not.toMatch(/(?:^|\s)docker\s+(?:tag|push)\s/u); expect(contract.run).toContain('(has("aliases") | not)'); expect(contract.run).not.toContain("aliases:"); }); + + it("holds every alias behind the exact six-candidate aggregate barrier (#7744)", () => { + const workflow = readWorkflow("managed-images.yaml"); + const promoter = managedPromoter(workflow); + const steps = promoter.steps ?? []; + const barrier = step(promoter, "Validate complete managed image candidate set"); + const promotion = step(promoter, "Promote validated multi-platform managed image cohort"); + + expect(promoter.needs).toBe("build-and-validate"); + expect(step(promoter, "Download all validated managed image candidates").with).toEqual({ + pattern: "managed-image-candidate-*", + path: "${{ runner.temp }}/managed-image-candidates", + "merge-multiple": false, + }); + expect(barrier.run).toContain("expected exactly six managed image candidate artifacts"); + expect(barrier.run).toContain("length == 6"); + expect(barrier.run).toContain('([.[].platform] | sort) == ["linux/amd64", "linux/arm64"]'); + expect(barrier.run).toContain("([.[].reference] | unique | length) == 6"); + expect(barrier.run).toContain("([.[].baseReference] | unique | length) == 6"); + expect(barrier.run).not.toContain("docker buildx imagetools create"); + expect(steps.indexOf(barrier)).toBeLessThan(steps.indexOf(promotion)); + + expect(promotion.run).toContain("for agent in openclaw hermes langchain-deepagents-code"); + expect(promotion.run).toContain( + 'docker buildx imagetools create --tag "$cohort_alias" "${sources[@]}"', + ); + expect(promotion.run).toContain(') == ["linux/amd64", "linux/arm64"]'); + expect(promotion.run).toContain('DOCKER_CONFIG="$anonymous_config" docker pull'); + expect(promotion.run).toContain( + 'consumer_aliases=("$(jq -r \'.image\' <<<"$openclaw_manifest"):${GITHUB_SHA}")', + ); + expect(promotion.run).not.toContain(":latest"); + }); + + it("fails the barrier before alias code when either architecture is absent (#7744)", () => { + const promoter = managedPromoter(readWorkflow("managed-images.yaml")); + const barrier = step(promoter, "Validate complete managed image candidate set"); + const promotion = step(promoter, "Promote validated multi-platform managed image cohort"); + const result = runPublicationBarrier( + barrier.run ?? "", + (candidates) => candidates.slice(0, -1), + promotion.run, + ); + + expect(result.status).not.toBe(0); + expect(result.stderr).toContain("expected exactly six managed image candidate artifacts"); + expect(result.dockerCalls).toEqual([]); + expect(barrier.run).not.toContain("imagetools create"); + }); + + it("fails the barrier before alias code on a duplicated architecture (#7744)", () => { + const promoter = managedPromoter(readWorkflow("managed-images.yaml")); + const barrier = step(promoter, "Validate complete managed image candidate set"); + const promotion = step(promoter, "Promote validated multi-platform managed image cohort"); + const result = runPublicationBarrier( + barrier.run ?? "", + (candidates) => + candidates.map((candidate) => + candidate.artifact === "managed-image-candidate-openclaw-linux-arm64" + ? { + ...candidate, + contract: { ...candidate.contract, platform: "linux/amd64" }, + } + : candidate, + ), + promotion.run, + ); + + expect(result.status).not.toBe(0); + expect(result.stderr).toContain("candidate artifact identity is invalid"); + expect(result.dockerCalls).toEqual([]); + expect(barrier.run).not.toContain("imagetools create"); + }); + + it("fails the barrier before alias code on a mixed-run cohort (#7744)", () => { + const promoter = managedPromoter(readWorkflow("managed-images.yaml")); + const barrier = step(promoter, "Validate complete managed image candidate set"); + const promotion = step(promoter, "Promote validated multi-platform managed image cohort"); + const result = runPublicationBarrier( + barrier.run ?? "", + (candidates) => + candidates.map((candidate, index) => + index === 0 + ? { + ...candidate, + contract: { + ...candidate.contract, + source: { + ...(candidate.contract.source as Record), + revision: "b".repeat(40), + }, + }, + } + : candidate, + ), + promotion.run, + ); + + expect(result.status).not.toBe(0); + expect(result.stderr).toContain( + "complete managed image candidate set failed closed validation", + ); + expect(result.dockerCalls).toEqual([]); + expect(barrier.run).not.toContain("imagetools create"); + }); + + it("accepts one exact candidate for every agent and architecture (#7744)", () => { + const barrier = step( + managedPromoter(readWorkflow("managed-images.yaml")), + "Validate complete managed image candidate set", + ); + + expect(runPublicationBarrier(barrier.run ?? "").status).toBe(0); + }); + + it("stages all multi-platform cohort aliases before moving the sole root pointer (#7744)", () => { + const promotion = required( + step( + managedPromoter(readWorkflow("managed-images.yaml")), + "Promote validated multi-platform managed image cohort", + ).run, + "managed image promotion script is missing", + ); + const cohort = "ghrun-7744-2"; + const revision = "a".repeat(40); + + const failed = runManagedImagePromotion(promotion, "langchain-deepagents-code"); + const failedCalls = failed.calls.join("\n"); + expect(failed.status, failed.stderr).toBe(91); + expect(failedCalls).toContain(`hermes-sandbox:cohort-${cohort}`); + expect(failedCalls).toContain(`langchain-deepagents-code-sandbox:cohort-${cohort}`); + expect(failedCalls).toContain(`openclaw-sandbox:cohort-${cohort}`); + expect(failedCalls).not.toContain(`openclaw-sandbox:${revision}`); + + const accepted = runManagedImagePromotion(promotion); + const acceptedCalls = accepted.calls.join("\n"); + const lastCohortStage = Math.max( + acceptedCalls.indexOf(`hermes-sandbox:cohort-${cohort}`), + acceptedCalls.indexOf(`langchain-deepagents-code-sandbox:cohort-${cohort}`), + acceptedCalls.indexOf(`openclaw-sandbox:cohort-${cohort}`), + ); + const rootPointer = acceptedCalls.indexOf(`openclaw-sandbox:${revision}`); + + expect(accepted.status, accepted.stderr).toBe(0); + expect(lastCohortStage).toBeGreaterThanOrEqual(0); + expect(rootPointer).toBeGreaterThan(lastCohortStage); + expect(acceptedCalls).not.toContain(`hermes-sandbox:${revision}`); + expect(acceptedCalls).not.toContain(`langchain-deepagents-code-sandbox:${revision}`); + expect(Object.keys(accepted.platformContracts).sort()).toEqual( + publicationAgents + .flatMap((agent) => publicationPlatforms.map((platform) => `${agent}|${platform}`)) + .sort(), + ); + expect(accepted.cohortContract).toMatchObject({ + contractVersion: 1, + cohort, + platforms: ["linux/amd64", "linux/arm64"], + agents: { + openclaw: expect.objectContaining({ + platforms: expect.objectContaining({ + "linux/amd64": expect.any(Object), + "linux/arm64": expect.any(Object), + }), + }), + hermes: expect.any(Object), + "langchain-deepagents-code": expect.any(Object), + }, + }); + }); + + it("retains exact platform and aggregate cohort contracts for ninety days (#7744)", () => { + const promoter = managedPromoter(readWorkflow("managed-images.yaml")); + const uploads = (promoter.steps ?? []) + .filter((candidate) => candidate.uses?.startsWith("actions/upload-artifact@")) + .map((candidate) => candidate.with); + + expect(uploads).toEqual([ + { + name: "managed-image-cohort", + path: "${{ runner.temp }}/managed-image-contracts/cohort.json", + "if-no-files-found": "error", + "retention-days": 90, + }, + ...publicationAgents.flatMap((agent) => + publicationPlatforms.map((platform) => { + const artifactPlatform = platform.replace("/", "-"); + const displayAgent = + agent === "langchain-deepagents-code" ? "langchain-deepagents-code" : agent; + return { + name: `managed-image-${displayAgent}-${artifactPlatform}`, + path: `\${{ runner.temp }}/managed-image-contracts/${agent}/${artifactPlatform}/contract.json`, + "if-no-files-found": "error", + "retention-days": 90, + }; + }), + ), + ]); + }); }); From 682db000715d30bb721610bf78c2542b48706a60 Mon Sep 17 00:00:00 2001 From: Aaron Erickson Date: Thu, 30 Jul 2026 10:05:20 -0700 Subject: [PATCH 7/7] test(ci): name release-tag concurrency invariant Signed-off-by: Aaron Erickson --- test/managed-image-publication-workflow.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/managed-image-publication-workflow.test.ts b/test/managed-image-publication-workflow.test.ts index 8b957c00aa..7a4d487e40 100644 --- a/test/managed-image-publication-workflow.test.ts +++ b/test/managed-image-publication-workflow.test.ts @@ -201,7 +201,7 @@ function publicationBoundaryErrors(baseWorkflow: Workflow, managedWorkflow: Work } describe("complete managed-image publication workflow", () => { - it("starts after exact base contracts with complete main triggers and release-safe concurrency (#7744)", () => { + it("starts after exact base contracts with complete main triggers and does not cancel release-tag runs (#7744)", () => { const baseWorkflow = readWorkflow("base-image.yaml"); const managedWorkflow = readWorkflow("managed-images.yaml"); const publisher = required(