Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 10 additions & 6 deletions .github/workflows/base-image.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -27,8 +27,12 @@ on:
- "agents/langchain-deepagents-code/Dockerfile.base"
- "agents/langchain-deepagents-code/manifest.yaml"
- "agents/langchain-deepagents-code/requirements.lock"
- "agents/openclaw/mcporter-runtime/package.json"
- "agents/openclaw/mcporter-runtime/package-lock.json"
# Dockerfile.base validates min_openclaw_version from this file at build time.
- "nemoclaw-blueprint/blueprint.yaml"
- "scripts/lib/reviewed-npm-archive.mts"
- "scripts/lib/sandbox-rlimits.sh"
workflow_dispatch:
inputs:
openclaw_version:
Expand Down Expand Up @@ -123,8 +127,8 @@ jobs:
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
cache-from: type=gha
cache-to: type=gha,mode=max
cache-from: type=registry,ref=${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:buildcache
cache-to: type=registry,ref=${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:buildcache,mode=max
build-args: ${{ steps.production-build-args.outputs.openclaw_build_arg }}

build-and-push-hermes:
Expand Down Expand Up @@ -172,8 +176,8 @@ jobs:
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
cache-from: type=gha
cache-to: type=gha,mode=max
cache-from: type=registry,ref=${{ env.REGISTRY }}/nvidia/nemoclaw/hermes-sandbox-base:buildcache
cache-to: type=registry,ref=${{ env.REGISTRY }}/nvidia/nemoclaw/hermes-sandbox-base:buildcache,mode=max

build-and-push-langchain-deepagents-code:
if: github.repository == 'NVIDIA/NemoClaw'
Expand Down Expand Up @@ -220,5 +224,5 @@ jobs:
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
cache-from: type=gha
cache-to: type=gha,mode=max
cache-from: type=registry,ref=${{ env.REGISTRY }}/nvidia/nemoclaw/langchain-deepagents-code-sandbox-base:buildcache
cache-to: type=registry,ref=${{ env.REGISTRY }}/nvidia/nemoclaw/langchain-deepagents-code-sandbox-base:buildcache,mode=max
88 changes: 88 additions & 0 deletions .github/workflows/e2e.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -296,6 +296,39 @@ jobs:
exit 1
fi

- name: Set up DCode profile gate Buildx
id: dcode-profile-gate-buildx
if: ${{ matrix.id == 'ubuntu-repo-cloud-langchain-deepagents-code' }}
uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4.2.0
with:
driver-opts: default-load=true

- name: Route DCode profile gate Docker builds through Buildx
if: ${{ matrix.id == 'ubuntu-repo-cloud-langchain-deepagents-code' }}
env:
DCODE_PROFILE_GATE_BUILDER: ${{ steps.dcode-profile-gate-buildx.outputs.name }}
shell: bash
run: |
set -euo pipefail
test -n "${DCODE_PROFILE_GATE_BUILDER}"
printf 'BUILDX_BUILDER=%s\n' "${DCODE_PROFILE_GATE_BUILDER}" >> "${GITHUB_ENV}"

- name: Validate DCode profile gate production Docker build args
if: ${{ matrix.id == 'ubuntu-repo-cloud-langchain-deepagents-code' }}
run: scripts/check-production-build-args.sh

# Import the trusted publisher cache without allowing PR-controlled E2E
# builds to replace it. The negative gate still executes every reviewed
# Dockerfile and proves the expected production-image failure.
- name: Warm DCode profile gate base build cache
if: ${{ matrix.id == 'ubuntu-repo-cloud-langchain-deepagents-code' }}
uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0
with:
builder: ${{ steps.dcode-profile-gate-buildx.outputs.name }}
context: .
file: agents/langchain-deepagents-code/Dockerfile.base
cache-from: type=registry,ref=ghcr.io/nvidia/nemoclaw/langchain-deepagents-code-sandbox-base:buildcache

- name: Configure live E2E trace directory
env:
TARGET_ID: ${{ matrix.id }}
Expand Down Expand Up @@ -2335,6 +2368,36 @@ jobs:

- *dockerhub-auth

- &rebuild-buildx
name: Set up rebuild Buildx
id: rebuild-buildx
uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4.2.0
with:
driver-opts: default-load=true

- &route-rebuild-builds
name: Route rebuild Docker builds through Buildx
env:
REBUILD_BUILDER: ${{ steps.rebuild-buildx.outputs.name }}
shell: bash
run: |
set -euo pipefail
test -n "${REBUILD_BUILDER}"
printf 'BUILDX_BUILDER=%s\n' "${REBUILD_BUILDER}" >> "${GITHUB_ENV}"

- name: Validate OpenClaw rebuild production Docker build args
run: scripts/check-production-build-args.sh

# This job may build PR-controlled source, so it imports the trusted
# publisher cache read-only and keeps any new layers job-local.
- name: Warm current OpenClaw base build cache
uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0
with:
builder: ${{ steps.rebuild-buildx.outputs.name }}
context: .
file: Dockerfile.base
cache-from: type=registry,ref=ghcr.io/nvidia/nemoclaw/sandbox-base:buildcache

- name: Prepare E2E workspace
uses: NVIDIA/NemoClaw/.github/actions/prepare-e2e@50281ee84c4a6fc759da95ea28fc0b7d9c378a28

Expand Down Expand Up @@ -2406,6 +2469,23 @@ jobs:

- *dockerhub-auth

- *rebuild-buildx

- *route-rebuild-builds

- &validate-hermes-rebuild-build-args
name: Validate Hermes rebuild production Docker build args
run: scripts/check-production-build-args.sh

- &warm-current-hermes-base
name: Warm current Hermes base build cache
uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0
with:
builder: ${{ steps.rebuild-buildx.outputs.name }}
context: .
file: agents/hermes/Dockerfile.base
cache-from: type=registry,ref=ghcr.io/nvidia/nemoclaw/hermes-sandbox-base:buildcache

- name: Prepare E2E workspace
uses: NVIDIA/NemoClaw/.github/actions/prepare-e2e@50281ee84c4a6fc759da95ea28fc0b7d9c378a28
with:
Expand Down Expand Up @@ -2462,6 +2542,14 @@ jobs:

- *dockerhub-auth

- *rebuild-buildx

- *route-rebuild-builds

- *validate-hermes-rebuild-build-args

- *warm-current-hermes-base

- name: Prepare E2E workspace
uses: NVIDIA/NemoClaw/.github/actions/prepare-e2e@50281ee84c4a6fc759da95ea28fc0b7d9c378a28
with:
Expand Down
2 changes: 2 additions & 0 deletions src/lib/onboard/sandbox-prebuild.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@ describe("sandbox BuildKit prebuild", () => {
vi.stubEnv("DOCKER_HOST", "unix:///var/run/docker.sock");
vi.stubEnv("DOCKER_CONFIG", "/home/user/.docker-ci");
vi.stubEnv("DOCKER_CONTEXT", "remote-builder");
vi.stubEnv("BUILDX_BUILDER", "cache-builder");
vi.stubEnv("XDG_CONFIG_HOME", "/home/user/.config");
vi.stubEnv("HTTPS_PROXY", "http://proxy:8080");
vi.stubEnv("NVIDIA_INFERENCE_API_KEY", "secret");
Expand All @@ -68,6 +69,7 @@ describe("sandbox BuildKit prebuild", () => {
DOCKER_HOST: "unix:///var/run/docker.sock",
DOCKER_CONFIG: "/home/user/.docker-ci",
DOCKER_CONTEXT: "remote-builder",
BUILDX_BUILDER: "cache-builder",
XDG_CONFIG_HOME: "/home/user/.config",
HTTPS_PROXY: "http://proxy:8080",
});
Expand Down
1 change: 1 addition & 0 deletions src/lib/onboard/sandbox-prebuild.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ const TRUTHY_FLAG_VALUES = new Set(["1", "true", "yes", "on"]);
const FALSY_FLAG_VALUES = new Set(["0", "false", "no", "off"]);
const LOCAL_IMAGE_REPO = LOCAL_SANDBOX_IMAGE_REPO;
const DOCKER_ENV_NAMES = [
"BUILDX_BUILDER",
"DOCKER_API_VERSION",
"DOCKER_CERT_PATH",
"DOCKER_CONFIG",
Expand Down
12 changes: 12 additions & 0 deletions src/lib/sandbox-base-image/resolution-key.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,18 @@ describe("sandbox base-image resolution key", () => {
expect(createSandboxBaseImageResolutionKey(options(root))).not.toBe(before);
});

it("changes when a Dockerfile-copied runtime helper changes", () => {
const root = fixture();
const helper = path.join(root, "scripts", "lib", "sandbox-rlimits.sh");
fs.mkdirSync(path.dirname(helper), { recursive: true });
fs.writeFileSync(helper, "ulimit -n 1024\n");
const before = createSandboxBaseImageResolutionKey(options(root));

fs.writeFileSync(helper, "ulimit -n 2048\n");

expect(createSandboxBaseImageResolutionKey(options(root))).not.toBe(before);
});

it("changes when an agent-specific dependency lock changes (#6456)", () => {
const root = fixture();
const lockfile = path.join(root, "agents", "langchain-deepagents-code", "requirements.lock");
Expand Down
10 changes: 9 additions & 1 deletion src/lib/sandbox-base-image/source-identity.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -135,7 +135,15 @@ describe("sandbox base-image source identity", () => {
"Dockerfile.base",
"../outside/Dockerfile.base",
]),
).toEqual(["Dockerfile.base", "nemoclaw-blueprint/blueprint.yaml", agentDockerfile]);
).toEqual([
"Dockerfile.base",
"nemoclaw-blueprint/blueprint.yaml",
"scripts/lib/sandbox-rlimits.sh",
"agents/openclaw/mcporter-runtime/package.json",
"agents/openclaw/mcporter-runtime/package-lock.json",
"scripts/lib/reviewed-npm-archive.mts",
agentDockerfile,
]);
});

it("builds deterministic local tags from a source SHA and falls back without one", () => {
Expand Down
9 changes: 8 additions & 1 deletion src/lib/sandbox-base-image/source-identity.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,14 @@ import path from "node:path";

import { ROOT } from "../runner";

export const BASE_IMAGE_INPUT_PATHS = ["Dockerfile.base", "nemoclaw-blueprint/blueprint.yaml"];
export const BASE_IMAGE_INPUT_PATHS = [
"Dockerfile.base",
"nemoclaw-blueprint/blueprint.yaml",
"scripts/lib/sandbox-rlimits.sh",
"agents/openclaw/mcporter-runtime/package.json",
"agents/openclaw/mcporter-runtime/package-lock.json",
"scripts/lib/reviewed-npm-archive.mts",
];

export function normalizeBaseImageInputPaths(rootDir: string, paths: string[] = []): string[] {
const absoluteRootDir = path.resolve(rootDir);
Expand Down
91 changes: 78 additions & 13 deletions test/dcode-base-image-workflow.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,11 @@ type Publisher = {
dockerfile: string;
};

type RegistryCacheEntry = {
mode?: string;
ref?: string;
};

const repoRoot = path.resolve(import.meta.dirname, "..");
const workflow = YAML.parse(
fs.readFileSync(path.join(repoRoot, ".github", "workflows", "base-image.yaml"), "utf8"),
Expand All @@ -57,45 +62,75 @@ function publisherJobs(candidate: Workflow): Publisher[] {
});
}

function copiedLocks(dockerfile: string): string[] {
function copiedInputs(dockerfile: string): string[] {
return [
...fs
.readFileSync(path.join(repoRoot, dockerfile), "utf8")
.matchAll(/^COPY\s+(\S+\.lock)\s+/gm),
].map(([, lock]) => lock);
.matchAll(/^COPY\s+(?!--from=)(?:--\S+\s+)*(\S+)\s+\S+/gm),
].map(([, input]) => input);
}

function copiedLocks(dockerfile: string): string[] {
return copiedInputs(dockerfile).filter((input) => input.endsWith(".lock"));
}

function registryCacheEntries(value: unknown): RegistryCacheEntry[] {
return String(value ?? "")
.split(/\r?\n/u)
.map((entry) => entry.trim())
.filter((entry) => entry.split(",").includes("type=registry"))
.map((entry) =>
Object.fromEntries(
entry
.split(",")
.filter((field) => field !== "type=registry")
.map((field) => field.split("=", 2) as [string, string]),
),
);
}

function validatePublishers(candidate: Workflow): string[] {
const triggerPaths = candidate.on?.push?.paths ?? [];
return publisherJobs(candidate).flatMap(({ jobName, job, build, buildIndex, dockerfile }) => {
const publishers = publisherJobs(candidate);
const exportedCacheRefCounts = new Map<string, number>();
for (const { build } of publishers) {
const cacheRef = registryCacheEntries(build.with?.["cache-to"])[0]?.ref ?? "";
exportedCacheRefCounts.set(cacheRef, (exportedCacheRefCounts.get(cacheRef) ?? 0) + 1);
}

return publishers.flatMap(({ jobName, job, build, buildIndex, dockerfile }) => {
const steps = job.steps ?? [];
const metadata = steps.find((step) => step.id === "meta");
const guardIndex = steps.findIndex((step) =>
(step.run ?? "").includes("scripts/check-production-build-args.sh"),
);
const dockerfileExists =
dockerfile.length > 0 && fs.existsSync(path.join(repoRoot, dockerfile));
const copiedLockPaths = dockerfileExists ? copiedLocks(dockerfile) : [];
const copiedInputPaths = dockerfileExists ? copiedInputs(dockerfile) : [];
const dockerActions = steps.filter((step) => step.uses?.startsWith("docker/"));
const tags = String(metadata?.with?.tags ?? "");
const metadataImage = String(metadata?.with?.images ?? "");
const expectedCacheRef = `${metadataImage}:buildcache`;
const cacheFrom = registryCacheEntries(build.with?.["cache-from"]);
const cacheTo = registryCacheEntries(build.with?.["cache-to"]);
const importedCacheRef = cacheFrom[0]?.ref;
const exportedCacheRef = cacheTo[0]?.ref;

return [
...(!dockerfileExists ? [`${jobName} must publish from an existing Dockerfile`] : []),
...(!triggerPaths.includes(dockerfile)
? [`${jobName} Dockerfile must trigger the publisher workflow`]
: []),
...copiedLockPaths
.filter((lock) => !triggerPaths.includes(lock))
.map((lock) => `${jobName} copied lockfile must trigger the publisher workflow: ${lock}`),
...copiedInputPaths
.filter((input) => !triggerPaths.includes(input))
.map((input) => `${jobName} copied input must trigger the publisher workflow: ${input}`),
...(guardIndex < 0 || guardIndex >= buildIndex
? [`${jobName} must validate production build args before publishing`]
: []),
...(!metadata?.uses?.startsWith("docker/metadata-action@")
? [`${jobName} must derive publication metadata with docker/metadata-action`]
: []),
...(String(metadata?.with?.images ?? "").length === 0
? [`${jobName} must declare a publication image`]
: []),
...(metadataImage.length === 0 ? [`${jobName} must declare a publication image`] : []),
...(!tags.includes("type=ref,event=tag") ||
!tags.includes("type=raw,value=latest") ||
!tags.includes("type=sha,prefix=,format=short")
Expand All @@ -116,6 +151,24 @@ function validatePublishers(candidate: Workflow): string[] {
build.with?.labels !== "${{ steps.meta.outputs.labels }}"
? [`${jobName} must publish the reviewed metadata outputs`]
: []),
...(cacheFrom.length !== 1 || !importedCacheRef
? [`${jobName} cache-from must declare exactly one registry cache ref`]
: []),
...(cacheTo.length !== 1 || !exportedCacheRef
? [`${jobName} cache-to must declare exactly one registry cache ref`]
: []),
...(importedCacheRef !== exportedCacheRef
? [`${jobName} must import and export the same registry cache ref`]
: []),
...(cacheTo[0]?.mode !== "max"
? [`${jobName} must export its registry cache in max mode`]
: []),
...(exportedCacheRef && exportedCacheRef !== expectedCacheRef
? [`${jobName} registry cache must use its publication image buildcache tag`]
: []),
...(exportedCacheRef && exportedCacheRefCounts.get(exportedCacheRef) !== 1
? [`${jobName} must use a publisher-unique registry cache ref`]
: []),
];
});
}
Expand All @@ -128,7 +181,7 @@ function pinnedAptVersion(dockerfile: string, packageName: string): string {
}

describe("base-image publication behavior", () => {
// source-shape-contract: security -- Publisher mutations must preserve immutable actions and guarded production build arguments
// source-shape-contract: security -- Publisher mutations must preserve immutable actions, guarded arguments, and trusted registry cache ownership
it("accepts every discovered publisher and rejects supply-chain mutations", () => {
const publishers = publisherJobs(workflow);
expect(publishers.length).toBeGreaterThan(0);
Expand All @@ -137,11 +190,18 @@ describe("base-image publication behavior", () => {
const mutated = structuredClone(workflow);
const mutatedPublisher = publisherJobs(mutated)[0];
const mutatedSteps = mutatedPublisher.job.steps ?? [];
const otherPublisher = publisherJobs(mutated)[1];
const otherCacheRef = registryCacheEntries(otherPublisher.build.with?.["cache-to"])[0]?.ref;
const mutatedGuard = mutatedSteps.find((step) =>
(step.run ?? "").includes("scripts/check-production-build-args.sh"),
);
mutatedPublisher.build.uses = "docker/build-push-action@v7";
mutatedPublisher.build.with = { ...mutatedPublisher.build.with, push: false };
mutatedPublisher.build.with = {
...mutatedPublisher.build.with,
push: false,
"cache-from": "type=gha",
"cache-to": `type=registry,ref=${otherCacheRef}`,
};
mutatedGuard!.run = "true";

expect(validatePublishers(mutated)).toEqual(
Expand All @@ -150,6 +210,11 @@ describe("base-image publication behavior", () => {
`${mutatedPublisher.jobName} Docker action must use a full commit SHA: docker/build-push-action@v7`,
`${mutatedPublisher.jobName} build-push action must use a full commit SHA`,
`${mutatedPublisher.jobName} must push the built image`,
`${mutatedPublisher.jobName} cache-from must declare exactly one registry cache ref`,
`${mutatedPublisher.jobName} must import and export the same registry cache ref`,
`${mutatedPublisher.jobName} must export its registry cache in max mode`,
`${mutatedPublisher.jobName} registry cache must use its publication image buildcache tag`,
`${mutatedPublisher.jobName} must use a publisher-unique registry cache ref`,
]),
);
});
Expand Down
Loading
Loading