From 5f0bbb8ab98a72e4e0ee97047e9451e5f281739d Mon Sep 17 00:00:00 2001 From: Senthil Ravichandran Date: Fri, 24 Jul 2026 23:22:49 -0700 Subject: [PATCH 1/4] ci(images): build OpenClaw base image on native runners Signed-off-by: Senthil Ravichandran --- .github/workflows/base-image.yaml | 207 ++++++++++++++++++++- Dockerfile.base | 10 +- test/dcode-base-image-workflow.test.ts | 145 +++++++++++++-- test/openclaw-dependency-review.test.ts | 4 +- test/perl-critical-cve-remediation.test.ts | 11 +- 5 files changed, 345 insertions(+), 32 deletions(-) diff --git a/.github/workflows/base-image.yaml b/.github/workflows/base-image.yaml index 63252690d3..95422b0e9e 100644 --- a/.github/workflows/base-image.yaml +++ b/.github/workflows/base-image.yaml @@ -58,8 +58,121 @@ env: REGISTRY: ghcr.io jobs: - # Keep one declarative publisher configuration while giving each base image - # an independently observable matrix job and registry cache namespace. + # Build OpenClaw on native runners because its complete Perl regression suite + # is prohibitively slow under QEMU. Publish only immutable per-platform + # digests here; the dependent manifest job updates user-facing tags atomically. + build-openclaw-platforms: + name: Build OpenClaw base image (${{ matrix.arch }}) + if: github.repository == 'NVIDIA/NemoClaw' + runs-on: ${{ matrix.runner }} + timeout-minutes: 60 + strategy: + fail-fast: false + matrix: + include: + - agent: openclaw + arch: amd64 + platform: linux/amd64 + runner: ubuntu-24.04 + dockerfile: Dockerfile.base + image: nvidia/nemoclaw/sandbox-base + - agent: openclaw + arch: arm64 + platform: linux/arm64 + runner: ubuntu-24.04-arm + dockerfile: Dockerfile.base + image: nvidia/nemoclaw/sandbox-base + steps: + - name: Checkout + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + + - 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: Extract metadata + id: meta + uses: docker/metadata-action@dc802804100637a589fabce1cb79ff13a1411302 # v6.2.0 + env: + DOCKER_METADATA_SHORT_SHA_LENGTH: 8 + with: + images: ${{ env.REGISTRY }}/${{ matrix.image }} + tags: | + type=raw,value=latest,enable=${{ github.ref == 'refs/heads/main' }} + type=ref,event=tag + type=sha,prefix=,format=short + + - name: Validate production Docker build args + id: production-build-args + env: + AGENT: ${{ matrix.agent }} + OPENCLAW_VERSION_INPUT: ${{ inputs.openclaw_version }} + run: | + set -euo pipefail + build_args=() + openclaw_build_arg="" + if [ "$AGENT" = "openclaw" ] && [ -n "${OPENCLAW_VERSION_INPUT}" ]; then + openclaw_build_arg="OPENCLAW_VERSION=${OPENCLAW_VERSION_INPUT}" + build_args+=(--build-arg "$openclaw_build_arg") + fi + if [ "${#build_args[@]}" -gt 0 ]; then + scripts/check-production-build-args.sh "${build_args[@]}" + else + scripts/check-production-build-args.sh + fi + if [ "$AGENT" = "openclaw" ] && [ -n "${OPENCLAW_VERSION_INPUT}" ]; then + if [[ "$OPENCLAW_VERSION_INPUT" == *$'\r'* || "$OPENCLAW_VERSION_INPUT" == *$'\n'* ]]; then + echo "ERROR: OpenClaw version must not contain CR or LF characters." >&2 + exit 1 + fi + if [[ ! "$OPENCLAW_VERSION_INPUT" =~ ^[0-9]+([.][0-9]+)*$ ]]; then + echo "ERROR: OpenClaw version must be a whole decimal dotted version (for example, 2026.6.10)." >&2 + exit 1 + fi + fi + printf 'openclaw_build_arg=%s\n' "$openclaw_build_arg" >> "$GITHUB_OUTPUT" + + - name: Build and push platform digest + id: build + uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0 + with: + context: . + file: ${{ matrix.dockerfile }} + platforms: ${{ matrix.platform }} + labels: ${{ steps.meta.outputs.labels }} + outputs: type=image,name=${{ env.REGISTRY }}/${{ matrix.image }},push-by-digest=true,name-canonical=true,push=true + cache-from: type=registry,ref=${{ env.REGISTRY }}/${{ matrix.image }}:buildcache-${{ matrix.arch }} + cache-to: type=registry,ref=${{ env.REGISTRY }}/${{ matrix.image }}:buildcache-${{ matrix.arch }},mode=max + build-args: ${{ steps.production-build-args.outputs.openclaw_build_arg }} + + - name: Export platform digest + env: + DIGEST: ${{ steps.build.outputs.digest }} + run: | + set -euo pipefail + if [[ ! "$DIGEST" =~ ^sha256:[0-9a-f]{64}$ ]]; then + echo "ERROR: build did not return a valid sha256 digest: $DIGEST" >&2 + exit 1 + fi + mkdir -p "$RUNNER_TEMP/digests" + touch "$RUNNER_TEMP/digests/${DIGEST#sha256:}" + + - name: Upload platform digest + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: openclaw-base-digest-${{ matrix.arch }} + path: ${{ runner.temp }}/digests/* + if-no-files-found: error + retention-days: 1 + + # Hermes and Deep Agents Code still use the existing QEMU multi-platform + # publisher. Their build behavior and timeout remain unchanged. build-and-push: name: Build and push ${{ matrix.display_name }} base image if: github.repository == 'NVIDIA/NemoClaw' @@ -69,10 +182,6 @@ jobs: fail-fast: false matrix: include: - - agent: openclaw - display_name: OpenClaw - dockerfile: Dockerfile.base - image: nvidia/nemoclaw/sandbox-base - agent: hermes display_name: Hermes dockerfile: agents/hermes/Dockerfile.base @@ -152,3 +261,89 @@ jobs: cache-from: type=registry,ref=${{ env.REGISTRY }}/${{ matrix.image }}:buildcache cache-to: type=registry,ref=${{ env.REGISTRY }}/${{ matrix.image }}:buildcache,mode=max build-args: ${{ steps.production-build-args.outputs.openclaw_build_arg }} + + # Preserve the established required-check name while making tag publication + # contingent on both native platform builds succeeding. + build-and-push-openclaw: + name: Build and push OpenClaw base image + if: github.repository == 'NVIDIA/NemoClaw' + needs: build-openclaw-platforms + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - name: Download platform digests + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + pattern: openclaw-base-digest-* + path: ${{ runner.temp }}/digests + merge-multiple: true + + - 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: Extract metadata + id: meta + uses: docker/metadata-action@dc802804100637a589fabce1cb79ff13a1411302 # v6.2.0 + env: + DOCKER_METADATA_SHORT_SHA_LENGTH: 8 + with: + images: ${{ env.REGISTRY }}/nvidia/nemoclaw/sandbox-base + tags: | + type=raw,value=latest,enable=${{ github.ref == 'refs/heads/main' }} + type=ref,event=tag + type=sha,prefix=,format=short + + - name: Create and verify multi-platform manifest + env: + IMAGE: ${{ env.REGISTRY }}/nvidia/nemoclaw/sandbox-base + TAGS: ${{ steps.meta.outputs.tags }} + run: | + set -euo pipefail + shopt -s nullglob + digest_files=("$RUNNER_TEMP"/digests/*) + if [ "${#digest_files[@]}" -ne 2 ]; then + echo "ERROR: expected exactly two platform digests, found ${#digest_files[@]}." >&2 + exit 1 + fi + + sources=() + for digest_file in "${digest_files[@]}"; do + digest="$(basename "$digest_file")" + if [[ ! "$digest" =~ ^[0-9a-f]{64}$ ]]; then + echo "ERROR: invalid platform digest artifact: $digest" >&2 + exit 1 + fi + sources+=("$IMAGE@sha256:$digest") + done + + mapfile -t tags <<< "$TAGS" + tag_args=() + for tag in "${tags[@]}"; do + if [ -n "$tag" ]; then + tag_args+=(--tag "$tag") + fi + done + if [ "${#tag_args[@]}" -eq 0 ]; then + echo "ERROR: metadata did not produce any publication tags." >&2 + exit 1 + fi + + docker buildx imagetools create "${tag_args[@]}" "${sources[@]}" + first_tag="${tags[0]}" + actual_platforms="$( + docker buildx imagetools inspect "$first_tag" --raw \ + | jq -r '.manifests[] | select(.platform.os == "linux") | .platform.architecture' \ + | sort -u \ + | paste -sd, - + )" + if [ "$actual_platforms" != "amd64,arm64" ]; then + echo "ERROR: published manifest has unexpected platforms: $actual_platforms" >&2 + exit 1 + fi diff --git a/Dockerfile.base b/Dockerfile.base index a98b219ac4..2bedefea8e 100644 --- a/Dockerfile.base +++ b/Dockerfile.base @@ -76,13 +76,11 @@ RUN apt-get update && apt-get install -y --no-install-recommends \ WORKDIR /tmp/perl-source -# Perl Configure's d_syscallproto probe compiles and runs a target test through -# Buildx/QEMU. The QEMU-backed build returns a false negative for trixie's libc -# declaration, so pin the known target result instead of synthesizing a -# conflicting prototype. Remove this override when the QEMU-backed multi-arch -# build in .github/workflows/base-image.yaml passes without it. +# Pin the reviewed d_syscallproto result for trixie's libc so both native +# architectures use the same known declaration instead of relying on a +# Configure probe that previously returned a false negative under QEMU. # Perl's test_harness schedules independent upstream tests through TEST_JOBS. -# This preserves the full suite without serial QEMU execution. +# This preserves the full suite while using each native runner efficiently. RUN curl --proto '=https' --tlsv1.2 -fsSL \ --retry 5 --retry-all-errors --retry-delay 2 --connect-timeout 15 --max-time 120 \ -o /tmp/perl.tar.xz "https://www.cpan.org/src/5.0/perl-${PERL_VERSION}.tar.xz" \ diff --git a/test/dcode-base-image-workflow.test.ts b/test/dcode-base-image-workflow.test.ts index 4f44f3742a..795e46c9ba 100644 --- a/test/dcode-base-image-workflow.test.ts +++ b/test/dcode-base-image-workflow.test.ts @@ -21,12 +21,19 @@ type WorkflowStep = { type PublisherMatrixEntry = { agent?: string; + arch?: string; display_name?: string; dockerfile?: string; image?: string; + platform?: string; + runner?: string; }; type WorkflowJob = { + name?: string; + needs?: string | string[]; + "runs-on"?: string; + "timeout-minutes"?: number; strategy?: { "fail-fast"?: boolean; matrix?: { include?: PublisherMatrixEntry[] }; @@ -80,17 +87,38 @@ function publisherBuildSteps(candidate: Workflow): Omit - (job.strategy?.matrix?.include ?? []).map((matrix) => ({ - jobName: `${jobName} (${matrix.display_name ?? matrix.agent ?? "unnamed"})`, - job, - build, - buildIndex, - dockerfile: renderMatrixValue(build.with?.file, matrix), - matrix, - })), + (job.strategy?.matrix?.include ?? []) + .filter((matrix) => matrix.display_name) + .map((matrix) => ({ + jobName: `${jobName} (${matrix.display_name})`, + job, + build, + buildIndex, + dockerfile: renderMatrixValue(build.with?.file, matrix), + matrix, + })), ); } +function openClawPlatformPublishers(candidate: Workflow): Publisher[] { + const jobName = "build-openclaw-platforms"; + const job = candidate.jobs?.[jobName]; + const steps = job?.steps ?? []; + const buildIndex = steps.findIndex((step) => step.uses?.startsWith("docker/build-push-action@")); + const build = steps[buildIndex]; + if (!job || !build) { + return []; + } + return (job.strategy?.matrix?.include ?? []).map((matrix) => ({ + jobName: `${jobName} (${matrix.arch ?? "unnamed"})`, + job, + build, + buildIndex, + dockerfile: renderMatrixValue(build.with?.file, matrix), + matrix, + })); +} + function copiedInputs(dockerfile: string): string[] { return [ ...fs @@ -229,7 +257,7 @@ describe("base-image publication behavior", () => { // 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(publisherBuildSteps(workflow)).toHaveLength(1); + expect(publisherBuildSteps(workflow)).toHaveLength(2); expect( publishers.map(({ dockerfile, matrix }) => ({ agent: matrix.agent, @@ -237,11 +265,6 @@ describe("base-image publication behavior", () => { image: matrix.image, })), ).toEqual([ - { - agent: "openclaw", - dockerfile: "Dockerfile.base", - image: "nvidia/nemoclaw/sandbox-base", - }, { agent: "hermes", dockerfile: "agents/hermes/Dockerfile.base", @@ -304,8 +327,100 @@ describe("base-image publication behavior", () => { ); }); + it("publishes OpenClaw atomically from native architecture runners", () => { + const publishers = openClawPlatformPublishers(workflow); + const platformJob = workflow.jobs?.["build-openclaw-platforms"]; + const manifestJob = workflow.jobs?.["build-and-push-openclaw"]; + + expect(platformJob?.["timeout-minutes"]).toBe(60); + expect(platformJob?.strategy?.["fail-fast"]).toBe(false); + expect( + publishers.map(({ matrix }) => ({ + agent: matrix.agent, + arch: matrix.arch, + platform: matrix.platform, + runner: matrix.runner, + })), + ).toEqual([ + { + agent: "openclaw", + arch: "amd64", + platform: "linux/amd64", + runner: "ubuntu-24.04", + }, + { + agent: "openclaw", + arch: "arm64", + platform: "linux/arm64", + runner: "ubuntu-24.04-arm", + }, + ]); + + for (const { job, build, buildIndex, dockerfile, matrix } of publishers) { + const steps = job.steps ?? []; + const guardIndex = steps.findIndex((step) => + (step.run ?? "").includes("scripts/check-production-build-args.sh"), + ); + const digestExport = steps.find((step) => step.name === "Export platform digest"); + const digestUpload = steps.find((step) => step.name === "Upload platform digest"); + const cacheSuffix = `buildcache-${matrix.arch}`; + + expect(dockerfile).toBe("Dockerfile.base"); + expect(job["runs-on"]).toBe("${{ matrix.runner }}"); + expect(steps.some((step) => step.uses?.startsWith("docker/setup-qemu-action@"))).toBe(false); + expect(guardIndex).toBeGreaterThanOrEqual(0); + expect(guardIndex).toBeLessThan(buildIndex); + expect(hasAgentScopedOpenClawVersion(steps[guardIndex])).toBe(true); + expect(build.with?.platforms).toBe("${{ matrix.platform }}"); + expect(build.with?.outputs).toBe( + "type=image,name=${{ env.REGISTRY }}/${{ matrix.image }},push-by-digest=true,name-canonical=true,push=true", + ); + expect(build.with?.tags).toBeUndefined(); + expect(renderMatrixValue(build.with?.["cache-from"], matrix)).toContain(cacheSuffix); + expect(renderMatrixValue(build.with?.["cache-to"], matrix)).toContain( + `${cacheSuffix},mode=max`, + ); + expect(digestExport?.run).toContain("^sha256:[0-9a-f]{64}$"); + expect(digestUpload?.with?.name).toBe("openclaw-base-digest-${{ matrix.arch }}"); + for (const step of steps.filter((step) => step.uses)) { + expect(step.uses, `${matrix.arch}: ${step.name}`).toMatch(FULL_SHA_ACTION); + } + } + + expect(manifestJob?.name).toBe("Build and push OpenClaw base image"); + expect(manifestJob?.needs).toBe("build-openclaw-platforms"); + expect(manifestJob?.["timeout-minutes"]).toBe(10); + expect( + manifestJob?.steps?.some((step) => step.uses?.startsWith("docker/build-push-action@")), + ).toBe(false); + const download = manifestJob?.steps?.find((step) => step.name === "Download platform digests"); + const metadata = manifestJob?.steps?.find((step) => step.id === "meta"); + const createManifest = manifestJob?.steps?.find( + (step) => step.name === "Create and verify multi-platform manifest", + ); + expect(download?.with).toMatchObject({ + pattern: "openclaw-base-digest-*", + "merge-multiple": true, + }); + expect(metadata?.with?.images).toBe("${{ env.REGISTRY }}/nvidia/nemoclaw/sandbox-base"); + expect(metadata?.with?.tags).toContain("type=raw,value=latest"); + expect(metadata?.with?.tags).toContain("type=ref,event=tag"); + expect(metadata?.with?.tags).toContain("type=sha,prefix=,format=short"); + expect(createManifest?.run).toContain('"${#digest_files[@]}" -ne 2'); + expect(createManifest?.run).toContain( + 'docker buildx imagetools create "${tag_args[@]}" "${sources[@]}"', + ); + expect(createManifest?.run).toContain('"amd64,arm64"'); + for (const step of (manifestJob?.steps ?? []).filter((step) => step.uses)) { + expect(step.uses, step.name).toMatch(FULL_SHA_ACTION); + } + }); + it("keeps shared apt dependencies pinned and aligned across discovered base images (#6679)", () => { - const dockerfiles = publisherJobs(workflow).map(({ dockerfile }) => dockerfile); + const dockerfiles = [ + ...openClawPlatformPublishers(workflow).map(({ dockerfile }) => dockerfile), + ...publisherJobs(workflow).map(({ dockerfile }) => dockerfile), + ].filter((dockerfile, index, all) => all.indexOf(dockerfile) === index); const curlVersions = dockerfiles.map((dockerfile) => pinnedAptVersion(dockerfile, "curl")); expect(new Set(dockerfiles).size).toBe(dockerfiles.length); diff --git a/test/openclaw-dependency-review.test.ts b/test/openclaw-dependency-review.test.ts index 4edacc7452..70039cc9d5 100644 --- a/test/openclaw-dependency-review.test.ts +++ b/test/openclaw-dependency-review.test.ts @@ -668,8 +668,8 @@ grep -Fq -- '--phase post-agent-install' Dockerfile it("accepts reviewed base-image versions and rejects injected build arguments", () => { const baseImages = readYaml(".github/workflows/base-image.yaml"); - const buildAndPush = baseImages.jobs["build-and-push"] as WorkflowJob; - const guard = requiredStep(buildAndPush, "Validate production Docker build args"); + const buildOpenClawPlatforms = baseImages.jobs["build-openclaw-platforms"] as WorkflowJob; + const guard = requiredStep(buildOpenClawPlatforms, "Validate production Docker build args"); for (const [input, expectedOutput] of [ ["", "openclaw_build_arg=\n"], diff --git a/test/perl-critical-cve-remediation.test.ts b/test/perl-critical-cve-remediation.test.ts index 3ad1a29e2d..4e685dfd4e 100644 --- a/test/perl-critical-cve-remediation.test.ts +++ b/test/perl-critical-cve-remediation.test.ts @@ -47,9 +47,14 @@ describe("sandbox base critical Perl CVE remediation", () => { expect(checksum).toBeGreaterThan(download); expect(extract).toBeGreaterThan(checksum); expect(builder).toContain("-Dd_syscallproto=define"); - expect(builder).toContain("Perl Configure's d_syscallproto probe"); - expect(builder).toContain("Remove this override when the QEMU-backed multi-arch"); - expect(builder).toContain(".github/workflows/base-image.yaml"); + expect(builder).toContain("Pin the reviewed d_syscallproto result"); + expect(builder).toContain("each native runner efficiently"); + expect(baseImageWorkflow).toContain("runner: ubuntu-24.04"); + expect(baseImageWorkflow).toContain("runner: ubuntu-24.04-arm"); + expect(baseImageWorkflow).toContain("platform: linux/amd64"); + expect(baseImageWorkflow).toContain("platform: linux/arm64"); + expect(baseImageWorkflow).toContain("Build and push platform digest"); + expect(baseImageWorkflow).toContain("Create and verify multi-platform manifest"); expect(baseImageWorkflow).toContain("docker/setup-qemu-action"); expect(baseImageWorkflow).toContain("platforms: linux/amd64,linux/arm64"); expect(baseImageWorkflow).toContain("dockerfile: Dockerfile.base"); From cd3ef12b6b746bd1b60fd35dc234b1c7b45a543c Mon Sep 17 00:00:00 2001 From: Senthil Ravichandran Date: Fri, 24 Jul 2026 23:29:06 -0700 Subject: [PATCH 2/4] test(images): keep publisher contract setup linear Signed-off-by: Senthil Ravichandran --- test/dcode-base-image-workflow.test.ts | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/test/dcode-base-image-workflow.test.ts b/test/dcode-base-image-workflow.test.ts index 795e46c9ba..4b9aa3a36e 100644 --- a/test/dcode-base-image-workflow.test.ts +++ b/test/dcode-base-image-workflow.test.ts @@ -102,13 +102,10 @@ function publisherJobs(candidate: Workflow): Publisher[] { function openClawPlatformPublishers(candidate: Workflow): Publisher[] { const jobName = "build-openclaw-platforms"; - const job = candidate.jobs?.[jobName]; + const job = candidate.jobs?.[jobName] as WorkflowJob; const steps = job?.steps ?? []; const buildIndex = steps.findIndex((step) => step.uses?.startsWith("docker/build-push-action@")); - const build = steps[buildIndex]; - if (!job || !build) { - return []; - } + const build = steps[buildIndex] as WorkflowStep; return (job.strategy?.matrix?.include ?? []).map((matrix) => ({ jobName: `${jobName} (${matrix.arch ?? "unnamed"})`, job, From bcbf7e870df2cea658b3cee8d92d63686cffa917 Mon Sep 17 00:00:00 2001 From: Senthil Ravichandran Date: Fri, 24 Jul 2026 23:36:57 -0700 Subject: [PATCH 3/4] test(images): preserve OpenClaw trigger coverage Signed-off-by: Senthil Ravichandran --- test/dcode-base-image-workflow.test.ts | 171 ++++++++++++++----------- 1 file changed, 98 insertions(+), 73 deletions(-) diff --git a/test/dcode-base-image-workflow.test.ts b/test/dcode-base-image-workflow.test.ts index 4b9aa3a36e..fcf0790351 100644 --- a/test/dcode-base-image-workflow.test.ts +++ b/test/dcode-base-image-workflow.test.ts @@ -154,35 +154,12 @@ function hasAgentScopedOpenClawVersion(step: WorkflowStep | undefined): boolean ); } -function validatePublishers(candidate: Workflow): string[] { +function validatePublisherInputs(candidate: Workflow, publishers: Publisher[]): string[] { const triggerPaths = candidate.on?.push?.paths ?? []; - const publishers = publisherJobs(candidate); - const exportedCacheRefCounts = new Map(); - for (const { build, matrix } of publishers) { - const cacheRef = - registryCacheEntries(renderMatrixValue(build.with?.["cache-to"], matrix))[0]?.ref ?? ""; - exportedCacheRefCounts.set(cacheRef, (exportedCacheRefCounts.get(cacheRef) ?? 0) + 1); - } - - return publishers.flatMap(({ jobName, job, build, buildIndex, dockerfile, matrix }) => { - 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 guard = steps[guardIndex]; + return publishers.flatMap(({ jobName, dockerfile }) => { const dockerfileExists = dockerfile.length > 0 && fs.existsSync(path.join(repoRoot, dockerfile)); const copiedInputPaths = dockerfileExists ? copiedInputs(dockerfile) : []; - const dockerActions = steps.filter((step) => step.uses?.startsWith("docker/")); - const tags = String(metadata?.with?.tags ?? ""); - const metadataImage = renderMatrixValue(metadata?.with?.images, matrix); - const expectedCacheRef = `${metadataImage}:buildcache`; - const cacheFrom = registryCacheEntries(renderMatrixValue(build.with?.["cache-from"], matrix)); - const cacheTo = registryCacheEntries(renderMatrixValue(build.with?.["cache-to"], matrix)); - const importedCacheRef = cacheFrom[0]?.ref; - const exportedCacheRef = cacheTo[0]?.ref; - return [ ...(!dockerfileExists ? [`${jobName} must publish from an existing Dockerfile`] : []), ...(!triggerPaths.includes(dockerfile) @@ -191,58 +168,91 @@ function validatePublishers(candidate: Workflow): string[] { ...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`] - : []), - ...(!hasAgentScopedOpenClawVersion(guard) - ? [`${jobName} must scope OpenClaw version handling to the OpenClaw matrix entry`] - : []), - ...(!metadata?.uses?.startsWith("docker/metadata-action@") - ? [`${jobName} must derive publication metadata with docker/metadata-action`] - : []), - ...(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") - ? [`${jobName} must publish release, latest, and commit tags`] - : []), - ...dockerActions - .filter((step) => !FULL_SHA_ACTION.test(step.uses ?? "")) - .map((step) => `${jobName} Docker action must use a full commit SHA: ${step.uses}`), - ...(!FULL_SHA_ACTION.test(build.uses ?? "") - ? [`${jobName} build-push action must use a full commit SHA`] - : []), - ...(build.with?.context !== "." ? [`${jobName} must publish from repository context`] : []), - ...(build.with?.platforms !== "linux/amd64,linux/arm64" - ? [`${jobName} must publish both supported architectures`] - : []), - ...(build.with?.push !== true ? [`${jobName} must push the built image`] : []), - ...(build.with?.tags !== "${{ steps.meta.outputs.tags }}" || - 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`] - : []), ]; }); } +function validatePublishers(candidate: Workflow): string[] { + const publishers = publisherJobs(candidate); + const exportedCacheRefCounts = new Map(); + for (const { build, matrix } of publishers) { + const cacheRef = + registryCacheEntries(renderMatrixValue(build.with?.["cache-to"], matrix))[0]?.ref ?? ""; + exportedCacheRefCounts.set(cacheRef, (exportedCacheRefCounts.get(cacheRef) ?? 0) + 1); + } + + return [ + ...validatePublisherInputs(candidate, publishers), + ...publishers.flatMap(({ jobName, job, build, buildIndex, matrix }) => { + 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 guard = steps[guardIndex]; + const dockerActions = steps.filter((step) => step.uses?.startsWith("docker/")); + const tags = String(metadata?.with?.tags ?? ""); + const metadataImage = renderMatrixValue(metadata?.with?.images, matrix); + const expectedCacheRef = `${metadataImage}:buildcache`; + const cacheFrom = registryCacheEntries(renderMatrixValue(build.with?.["cache-from"], matrix)); + const cacheTo = registryCacheEntries(renderMatrixValue(build.with?.["cache-to"], matrix)); + const importedCacheRef = cacheFrom[0]?.ref; + const exportedCacheRef = cacheTo[0]?.ref; + + return [ + ...(guardIndex < 0 || guardIndex >= buildIndex + ? [`${jobName} must validate production build args before publishing`] + : []), + ...(!hasAgentScopedOpenClawVersion(guard) + ? [`${jobName} must scope OpenClaw version handling to the OpenClaw matrix entry`] + : []), + ...(!metadata?.uses?.startsWith("docker/metadata-action@") + ? [`${jobName} must derive publication metadata with docker/metadata-action`] + : []), + ...(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") + ? [`${jobName} must publish release, latest, and commit tags`] + : []), + ...dockerActions + .filter((step) => !FULL_SHA_ACTION.test(step.uses ?? "")) + .map((step) => `${jobName} Docker action must use a full commit SHA: ${step.uses}`), + ...(!FULL_SHA_ACTION.test(build.uses ?? "") + ? [`${jobName} build-push action must use a full commit SHA`] + : []), + ...(build.with?.context !== "." ? [`${jobName} must publish from repository context`] : []), + ...(build.with?.platforms !== "linux/amd64,linux/arm64" + ? [`${jobName} must publish both supported architectures`] + : []), + ...(build.with?.push !== true ? [`${jobName} must push the built image`] : []), + ...(build.with?.tags !== "${{ steps.meta.outputs.tags }}" || + 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`] + : []), + ]; + }), + ]; +} + function pinnedAptVersion(dockerfile: string, packageName: string): string { const source = fs.readFileSync(path.join(repoRoot, dockerfile), "utf8"); const version = source.match(new RegExp(`^\\s*${packageName}=([^\\s\\\\]+)`, "m"))?.[1]; @@ -275,6 +285,7 @@ describe("base-image publication behavior", () => { ]); expect(publishers[0].job.strategy?.["fail-fast"]).toBe(false); expect(validatePublishers(workflow)).toEqual([]); + expect(validatePublisherInputs(workflow, openClawPlatformPublishers(workflow))).toEqual([]); const mutated = structuredClone(workflow); const mutatedPublisher = publisherJobs(mutated)[0]; @@ -322,6 +333,20 @@ describe("base-image publication behavior", () => { expect(validatePublishers(invertedGate)).toContain( `${invertedPublisher.jobName} must scope OpenClaw version handling to the OpenClaw matrix entry`, ); + + const missingTriggers = structuredClone(workflow); + const copiedInput = copiedInputs("Dockerfile.base")[0]; + missingTriggers.on!.push!.paths = missingTriggers.on!.push!.paths!.filter( + (triggerPath) => triggerPath !== "Dockerfile.base" && triggerPath !== copiedInput, + ); + expect( + validatePublisherInputs(missingTriggers, openClawPlatformPublishers(missingTriggers)), + ).toEqual( + expect.arrayContaining([ + "build-openclaw-platforms (amd64) Dockerfile must trigger the publisher workflow", + `build-openclaw-platforms (arm64) copied input must trigger the publisher workflow: ${copiedInput}`, + ]), + ); }); it("publishes OpenClaw atomically from native architecture runners", () => { From 75093a8a780ac4b4c70d4ea6d701e82e0de0aa0d Mon Sep 17 00:00:00 2001 From: Senthil Ravichandran Date: Fri, 24 Jul 2026 23:43:08 -0700 Subject: [PATCH 4/4] docs(images): define Perl probe override removal Signed-off-by: Senthil Ravichandran --- Dockerfile.base | 2 ++ test/perl-critical-cve-remediation.test.ts | 2 ++ 2 files changed, 4 insertions(+) diff --git a/Dockerfile.base b/Dockerfile.base index 2bedefea8e..bc295089a3 100644 --- a/Dockerfile.base +++ b/Dockerfile.base @@ -79,6 +79,8 @@ WORKDIR /tmp/perl-source # Pin the reviewed d_syscallproto result for trixie's libc so both native # architectures use the same known declaration instead of relying on a # Configure probe that previously returned a false negative under QEMU. +# Remove this override only after the pinned base image and Perl release report +# d_syscallproto=define from native Configure probes on amd64 and arm64. # Perl's test_harness schedules independent upstream tests through TEST_JOBS. # This preserves the full suite while using each native runner efficiently. RUN curl --proto '=https' --tlsv1.2 -fsSL \ diff --git a/test/perl-critical-cve-remediation.test.ts b/test/perl-critical-cve-remediation.test.ts index 4e685dfd4e..f1565d0d36 100644 --- a/test/perl-critical-cve-remediation.test.ts +++ b/test/perl-critical-cve-remediation.test.ts @@ -48,6 +48,8 @@ describe("sandbox base critical Perl CVE remediation", () => { expect(extract).toBeGreaterThan(checksum); expect(builder).toContain("-Dd_syscallproto=define"); expect(builder).toContain("Pin the reviewed d_syscallproto result"); + expect(builder).toContain("Remove this override only after the pinned base image"); + expect(builder).toContain("native Configure probes on amd64 and arm64"); expect(builder).toContain("each native runner efficiently"); expect(baseImageWorkflow).toContain("runner: ubuntu-24.04"); expect(baseImageWorkflow).toContain("runner: ubuntu-24.04-arm");