diff --git a/.github/workflows/base-image.yaml b/.github/workflows/base-image.yaml index 3cd10c3fda..ef08ead38a 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" @@ -59,8 +72,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 @@ -450,6 +463,7 @@ jobs: type=sha,prefix=,format=short - name: Create and verify multi-platform manifest + id: manifest env: IMAGE: ${{ env.REGISTRY }}/nvidia/nemoclaw/hermes-sandbox-base TAGS: ${{ steps.meta.outputs.tags }} @@ -463,6 +477,7 @@ jobs: fi declare -A seen_arches=() + declare -A platform_digests=() sources=() for digest_file in "${digest_files[@]}"; do digest_artifact="$(basename "$digest_file")" @@ -486,6 +501,7 @@ jobs: exit 1 fi seen_arches["$expected_arch"]=1 + platform_digests["linux/$expected_arch"]="sha256:$digest" sources+=("$source") done if [ "${seen_arches[amd64]:-0}" -ne 1 ] || [ "${seen_arches[arm64]:-0}" -ne 1 ]; then @@ -517,6 +533,80 @@ 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 Hermes base digest." >&2 + exit 1 + fi + digest="${manifest_digests[0]}" + reference="$IMAGE@$digest" + docker buildx imagetools inspect "$reference" >/dev/null + + contract_dir="$RUNNER_TEMP/managed-base-contract" + mkdir -p "$contract_dir" + jq -n \ + --arg amd64Digest "${platform_digests[linux/amd64]}" \ + --arg amd64Reference "$IMAGE@${platform_digests[linux/amd64]}" \ + --arg arm64Digest "${platform_digests[linux/arm64]}" \ + --arg arm64Reference "$IMAGE@${platform_digests[linux/arm64]}" \ + --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: "hermes", + image: $image, + digest: $digest, + reference: $reference, + platforms: ["linux/amd64", "linux/arm64"], + platformDigests: { + "linux/amd64": $amd64Digest, + "linux/arm64": $arm64Digest + }, + platformReferences: { + "linux/amd64": $amd64Reference, + "linux/arm64": $arm64Reference + }, + sourceRevision: $revision, + run: { + id: $runId, + attempt: $runAttempt + } + }' > "$contract_dir/contract.json" + jq -e \ + '.contractVersion == 1 + and .agent == "hermes" + 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 (.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 + printf 'digest=%s\n' "$digest" >> "$GITHUB_OUTPUT" + + - name: Upload managed base image contract + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: managed-base-hermes + path: ${{ runner.temp }}/managed-base-contract/contract.json + if-no-files-found: error + retention-days: 1 build-and-push-dcode: name: Build and push Deep Agents Code base image @@ -555,6 +645,7 @@ jobs: type=sha,prefix=,format=short - name: Create and verify multi-platform manifest + id: manifest env: IMAGE: ${{ env.REGISTRY }}/nvidia/nemoclaw/langchain-deepagents-code-sandbox-base TAGS: ${{ steps.meta.outputs.tags }} @@ -568,6 +659,7 @@ jobs: fi declare -A seen_arches=() + declare -A platform_digests=() sources=() for digest_file in "${digest_files[@]}"; do digest_artifact="$(basename "$digest_file")" @@ -591,6 +683,7 @@ jobs: exit 1 fi seen_arches["$expected_arch"]=1 + platform_digests["linux/$expected_arch"]="sha256:$digest" sources+=("$source") done if [ "${seen_arches[amd64]:-0}" -ne 1 ] || [ "${seen_arches[arm64]:-0}" -ne 1 ]; then @@ -622,6 +715,80 @@ 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 Deep Agents Code base digest." >&2 + exit 1 + fi + digest="${manifest_digests[0]}" + reference="$IMAGE@$digest" + docker buildx imagetools inspect "$reference" >/dev/null + + contract_dir="$RUNNER_TEMP/managed-base-contract" + mkdir -p "$contract_dir" + jq -n \ + --arg amd64Digest "${platform_digests[linux/amd64]}" \ + --arg amd64Reference "$IMAGE@${platform_digests[linux/amd64]}" \ + --arg arm64Digest "${platform_digests[linux/arm64]}" \ + --arg arm64Reference "$IMAGE@${platform_digests[linux/arm64]}" \ + --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: "langchain-deepagents-code", + image: $image, + digest: $digest, + reference: $reference, + platforms: ["linux/amd64", "linux/arm64"], + platformDigests: { + "linux/amd64": $amd64Digest, + "linux/arm64": $arm64Digest + }, + platformReferences: { + "linux/amd64": $amd64Reference, + "linux/arm64": $arm64Reference + }, + sourceRevision: $revision, + run: { + id: $runId, + attempt: $runAttempt + } + }' > "$contract_dir/contract.json" + jq -e \ + '.contractVersion == 1 + and .agent == "langchain-deepagents-code" + 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 (.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 + printf 'digest=%s\n' "$digest" >> "$GITHUB_OUTPUT" + + - name: Upload managed base image contract + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: managed-base-langchain-deepagents-code + 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. @@ -662,6 +829,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 }} @@ -675,6 +843,7 @@ jobs: fi declare -A seen_arches=() + declare -A platform_digests=() sources=() for digest_file in "${digest_files[@]}"; do digest_artifact="$(basename "$digest_file")" @@ -698,6 +867,7 @@ jobs: exit 1 fi seen_arches["$expected_arch"]=1 + platform_digests["linux/$expected_arch"]="sha256:$digest" sources+=("$source") done if [ "${seen_arches[amd64]:-0}" -ne 1 ] || [ "${seen_arches[arm64]:-0}" -ne 1 ]; then @@ -729,3 +899,95 @@ 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]}" + reference="$IMAGE@$digest" + docker buildx imagetools inspect "$reference" >/dev/null + + contract_dir="$RUNNER_TEMP/managed-base-contract" + mkdir -p "$contract_dir" + jq -n \ + --arg amd64Digest "${platform_digests[linux/amd64]}" \ + --arg amd64Reference "$IMAGE@${platform_digests[linux/amd64]}" \ + --arg arm64Digest "${platform_digests[linux/arm64]}" \ + --arg arm64Reference "$IMAGE@${platform_digests[linux/arm64]}" \ + --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"], + platformDigests: { + "linux/amd64": $amd64Digest, + "linux/arm64": $arm64Digest + }, + platformReferences: { + "linux/amd64": $amd64Reference, + "linux/arm64": $arm64Reference + }, + 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"] + 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 + printf 'digest=%s\n' "$digest" >> "$GITHUB_OUTPUT" + + - 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 + + # Consume the three exact base-image contracts in this run. The reusable + # publisher promotes no mutable cohort alias until every agent and platform + # has passed its immutable-digest validation. + 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-hermes + - build-and-push-dcode + - 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..bb64cbb452 --- /dev/null +++ b/.github/workflows/managed-images.yaml @@ -0,0 +1,1014 @@ +# 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. 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 + +on: + workflow_call: + +permissions: + contents: read + packages: write + +env: + REGISTRY: ghcr.io + +jobs: + build-and-validate: + 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 + image: nvidia/nemoclaw/openclaw-sandbox + 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 + image: nvidia/nemoclaw/hermes-sandbox + 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 + image: nvidia/nemoclaw/langchain-deepagents-code-sandbox + 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: + 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", + "platformDigests", + "platformReferences", + "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 == ["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 + then + echo "ERROR: exact base image contract failed closed validation." >&2 + exit 1 + fi + 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 + 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 }} + 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 + provenance: mode=max + sbom: true + + - name: Validate exact managed image + id: validate + shell: bash + env: + AGENT: ${{ matrix.agent }} + 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 + 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" + )" + 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"}}' \ + "$reference" + )" + 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 + 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: 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 + 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" \ + --arg baseReference "$BASE_REFERENCE" \ + --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, + 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" \ + '(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 .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 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-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/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/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 new file mode 100644 index 0000000000..7a4d487e40 --- /dev/null +++ b/test/managed-image-publication-workflow.test.ts @@ -0,0 +1,658 @@ +// 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"; + +import { + publicationAgents, + publicationPlatforms, + runManagedImagePromotion, + runPublicationBarrier, +} from "./helpers/managed-image-publication-barrier"; + +type Step = { + env?: Record; + id?: string; + name?: string; + run?: string; + uses?: string; + with?: Record; +}; + +type MatrixEntry = { + agent?: string; + arch?: string; + artifact_platform?: string; + base_image?: string; + display_name?: string; + dockerfile?: string; + image?: string; + platform?: string; + required_binary?: string; + runner?: 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-and-validate"], + "managed-image workflow is missing its publisher", + ); +} + +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( + baseWorkflow.jobs?.["publish-managed-images"], + "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"', + "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 forbiddenPerLanePromotionMarkers = [ + 'aliases=("${IMAGE}:${GITHUB_SHA}")', + "docker buildx imagetools create", + "docker tag ", + "docker push ", + ]; + const buildIndex = steps.indexOf(build); + const validateIndex = steps.indexOf(validate); + + 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}`), + ...forbiddenPerLanePromotionMarkers + .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"]), + ]; +} + +describe("complete managed-image publication workflow", () => { + 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( + 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-hermes", "build-and-push-dcode", "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 basePublishers = [ + { + agent: "hermes", + artifact: "managed-base-hermes", + job: "build-and-push-hermes", + platformsJob: "build-hermes-platforms", + }, + { + agent: "langchain-deepagents-code", + artifact: "managed-base-langchain-deepagents-code", + job: "build-and-push-dcode", + platformsJob: "build-dcode-platforms", + }, + { + agent: "openclaw", + artifact: "managed-base-openclaw", + job: "build-and-push-openclaw", + platformsJob: "build-openclaw-platforms", + }, + ] as const; + + for (const expectedPublisher of basePublishers) { + const basePublisher = required( + baseWorkflow.jobs?.[expectedPublisher.job], + `base-image workflow is missing ${expectedPublisher.agent} manifest publisher`, + ); + const manifest = step(basePublisher, "Create and verify multi-platform manifest"); + expect(manifest.id).toBe("manifest"); + expect(manifest.run).toContain('reference="$IMAGE@$digest"'); + expect(manifest.run).toContain(`agent: "${expectedPublisher.agent}"`); + expect(manifest.run).toContain("platformDigests: {"); + expect(step(basePublisher, "Upload managed base image contract").with?.name).toBe( + expectedPublisher.artifact, + ); + + const nativePlatforms = required( + baseWorkflow.jobs?.[expectedPublisher.platformsJob], + `base-image workflow is missing native ${expectedPublisher.agent} platforms`, + ); + expect(nativePlatforms.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 an exact native amd64 and arm64 lane for every shipped agent (#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("${{ 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", + image: "nvidia/nemoclaw/openclaw-sandbox", + 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", + image: "nvidia/nemoclaw/hermes-sandbox", + 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", + image: "nvidia/nemoclaw/langchain-deepagents-code-sandbox", + 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, ...(promoter.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"); + expect(build.with?.labels).toContain( + "io.nvidia.nemoclaw.managed-image.cohort=ghrun-${{ github.run_id }}-${{ github.run_attempt }}", + ); + + 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 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": 1, + }); + }); + + 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 validated managed image candidate"); + + 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("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, + }; + }), + ), + ]); + }); +}); 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); }