From 1c53feca65fcc30cf9ef7dbbefbc9bb16f4ef215 Mon Sep 17 00:00:00 2001 From: Julie Yaunches Date: Tue, 18 Aug 2026 21:25:09 -0400 Subject: [PATCH 01/12] test(ci): reproduce managed image rerun artifact loss Signed-off-by: Julie Yaunches --- ci/source-shape-test-budget.json | 5 ++ test/managed-image-rerun-artifacts.test.ts | 76 ++++++++++++++++++++++ 2 files changed, 81 insertions(+) create mode 100644 test/managed-image-rerun-artifacts.test.ts diff --git a/ci/source-shape-test-budget.json b/ci/source-shape-test-budget.json index 5eb801c3dfc..fe4b3ede951 100644 --- a/ci/source-shape-test-budget.json +++ b/ci/source-shape-test-budget.json @@ -76,6 +76,11 @@ "test": "restores exact locked posture after root-separated repair and later failure (#7033)", "category": "security" }, + { + "file": "test/managed-image-rerun-artifacts.test.ts", + "test": "retains producer artifact identities during a failed-job rerun (#9529)", + "category": "security" + }, { "file": "test/muse-glimmer-vllm-image-provenance.test.ts", "test": "rejects %s", diff --git a/test/managed-image-rerun-artifacts.test.ts b/test/managed-image-rerun-artifacts.test.ts new file mode 100644 index 00000000000..db8dd4bd96b --- /dev/null +++ b/test/managed-image-rerun-artifacts.test.ts @@ -0,0 +1,76 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import fs from "node:fs"; +import path from "node:path"; + +import { describe, expect, it } from "vitest"; +import YAML from "yaml"; + +type Step = { + name?: string; + with?: Record; +}; + +type Workflow = { + jobs?: Record; +}; + +const repoRoot = path.resolve(import.meta.dirname, ".."); + +function readYaml(file: string): Workflow { + return YAML.parse(fs.readFileSync(path.join(repoRoot, file), "utf8")) as Workflow; +} + +function requiredStep(steps: Step[] | undefined, name: string): Step { + return ( + steps?.find((candidate) => candidate.name === name) ?? + (() => { + throw new Error(`workflow is missing '${name}'`); + })() + ); +} + +function renderArtifactIdentity(value: unknown, runAttempt: number): string { + return String(value) + .replaceAll("${{ github.run_id }}", "32191102997") + .replaceAll("${{ github.run_attempt }}", String(runAttempt)) + .replaceAll("${{ inputs.agent }}", "openclaw") + .replaceAll("${{ matrix.agent }}", "openclaw") + .replaceAll("${{ matrix.artifact_platform }}", "linux-amd64"); +} + +describe("managed-image failed-job rerun artifacts", () => { + // source-shape-contract: security -- Producer artifact identity must remain exact when GitHub reuses a successful job during a failed-job rerun + it("retains producer artifact identities during a failed-job rerun (#9529)", () => { + const baseAction = readYaml( + ".github/actions/publish-base-image-manifest/action.yaml", + ) as Workflow & { runs?: { steps?: Step[] } }; + const workflow = readYaml(".github/workflows/managed-images.yaml"); + const publisher = workflow.jobs?.["build-and-validate"]; + const promoter = workflow.jobs?.promote; + const baseUpload = requiredStep(baseAction.runs?.steps, "Upload managed base image contract"); + const baseDownload = requiredStep(publisher?.steps, "Download exact base image contract"); + const candidateUpload = requiredStep( + publisher?.steps, + "Upload validated managed image candidate", + ); + const candidateDownload = requiredStep( + promoter?.steps, + "Download all validated managed image candidates", + ); + + const producerBaseName = renderArtifactIdentity(baseUpload.with?.name, 1); + const rerunBaseName = renderArtifactIdentity(baseDownload.with?.name, 2); + const producerCandidateName = renderArtifactIdentity(candidateUpload.with?.name, 1); + const rerunCandidatePrefix = renderArtifactIdentity(candidateDownload.with?.pattern, 2).replace( + /\*$/u, + "", + ); + + expect([ + rerunBaseName === producerBaseName, + producerCandidateName.startsWith(rerunCandidatePrefix), + ]).toEqual([true, true]); + }); +}); From 36748b082bf98d6f231e09cdd49e248193f25d6d Mon Sep 17 00:00:00 2001 From: Julie Yaunches Date: Tue, 18 Aug 2026 22:12:39 -0400 Subject: [PATCH 02/12] fix(ci): retain managed image rerun outputs Signed-off-by: Julie Yaunches --- .../build-base-image-platform/action.yaml | 10 + .../publish-base-image-manifest/action.yaml | 50 +++- .github/workflows/base-image.yaml | 40 +++ .github/workflows/managed-images.yaml | 235 +++++++++++++++--- ci/source-shape-test-budget.json | 2 +- .../managed-image-publication-barrier.ts | 16 ++ ...anaged-image-publication-workflow-types.ts | 6 +- ...managed-image-publication-workflow.test.ts | 114 ++++----- test/managed-image-rerun-artifacts.test.ts | 210 +++++++++++++--- 9 files changed, 546 insertions(+), 137 deletions(-) diff --git a/.github/actions/build-base-image-platform/action.yaml b/.github/actions/build-base-image-platform/action.yaml index 381b34b1efb..7d2b46ae565 100644 --- a/.github/actions/build-base-image-platform/action.yaml +++ b/.github/actions/build-base-image-platform/action.yaml @@ -4,6 +4,14 @@ name: build-base-image-platform description: Build and publish one immutable base image digest for a platform. +outputs: + amd64-digest: + description: Exact amd64 digest when this action built the amd64 lane. + value: ${{ steps.job-output.outputs.amd64_digest }} + arm64-digest: + description: Exact arm64 digest when this action built the arm64 lane. + value: ${{ steps.job-output.outputs.arm64_digest }} + inputs: agent: description: Agent identifier used for build arguments and artifact names. @@ -129,6 +137,7 @@ runs: 'test -x /usr/bin/dos2unix; test "$(command -v dos2unix)" = /usr/bin/dos2unix; dos2unix --version >/dev/null' - name: Export platform digest + id: job-output shell: bash env: ARCH: ${{ inputs.arch }} @@ -145,6 +154,7 @@ runs: fi mkdir -p "$RUNNER_TEMP/digests" touch "$RUNNER_TEMP/digests/${ARCH}-${DIGEST#sha256:}" + printf '%s_digest=%s\n' "$ARCH" "$DIGEST" >> "$GITHUB_OUTPUT" - name: Upload platform digest uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 diff --git a/.github/actions/publish-base-image-manifest/action.yaml b/.github/actions/publish-base-image-manifest/action.yaml index 211186a797b..25607d58a91 100644 --- a/.github/actions/publish-base-image-manifest/action.yaml +++ b/.github/actions/publish-base-image-manifest/action.yaml @@ -4,10 +4,21 @@ name: publish-base-image-manifest description: Validate platform digests and publish one multi-platform base image manifest. +outputs: + contract-base64: + description: Exact validated base-image contract for dependent jobs. + value: ${{ steps.contract-output.outputs.contract_base64 }} + inputs: agent: description: Agent identifier stored in the managed base image contract. required: true + amd64-digest: + description: Exact amd64 platform digest from the producer job. + required: true + arm64-digest: + description: Exact arm64 platform digest from the producer job. + required: true display-name: description: Agent name used in validation errors. required: true @@ -27,12 +38,23 @@ inputs: runs: using: composite steps: - - name: Download platform digests - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 - with: - pattern: ${{ inputs.agent }}-base-digest-${{ github.run_id }}-${{ github.run_attempt }}-* - path: ${{ runner.temp }}/digests - merge-multiple: true + - name: Restore exact platform digests + shell: bash + env: + AMD64_DIGEST: ${{ inputs.amd64-digest }} + ARM64_DIGEST: ${{ inputs.arm64-digest }} + run: | + set -euo pipefail + for digest in "$AMD64_DIGEST" "$ARM64_DIGEST"; do + [[ "$digest" =~ ^sha256:[0-9a-f]{64}$ ]] || { + echo "ERROR: base-image producer output has an invalid digest." >&2 + exit 1 + } + done + digest_root="$RUNNER_TEMP/digests" + install -d -m 0700 "$digest_root" + touch "$digest_root/amd64-${AMD64_DIGEST#sha256:}" + touch "$digest_root/arm64-${ARM64_DIGEST#sha256:}" - name: Set up Docker Buildx uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4.2.0 @@ -65,6 +87,22 @@ runs: IMAGE: ${{ inputs.registry }}/${{ inputs.image }} TAGS: ${{ steps.meta.outputs.tags }} run: bash "$GITHUB_ACTION_PATH/publish.sh" + - name: Export managed base image contract + id: contract-output + shell: bash + run: | + set -euo pipefail + contract="$RUNNER_TEMP/managed-base-contract/contract.json" + if [ ! -f "$contract" ] || [ -L "$contract" ]; then + echo "ERROR: managed base image contract is missing or unsafe." >&2 + exit 1 + fi + contract_size="$(wc -c < "$contract" | tr -d '[:space:]')" + if [[ ! "$contract_size" =~ ^[1-9][0-9]{0,5}$ ]] || [ "$contract_size" -gt 65536 ]; then + echo "ERROR: managed base image contract is empty or oversized." >&2 + exit 1 + fi + printf 'contract_base64=%s\n' "$(base64 -w 0 "$contract")" >> "$GITHUB_OUTPUT" - name: Upload managed base image contract uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: diff --git a/.github/workflows/base-image.yaml b/.github/workflows/base-image.yaml index cf2fe40b408..5d2a1b97dc3 100644 --- a/.github/workflows/base-image.yaml +++ b/.github/workflows/base-image.yaml @@ -127,6 +127,9 @@ jobs: - reviewed-npm-audit runs-on: ${{ matrix.runner }} timeout-minutes: 60 + outputs: + amd64-digest: ${{ steps.platform.outputs.amd64-digest }} + arm64-digest: ${{ steps.platform.outputs.arm64-digest }} strategy: fail-fast: false matrix: @@ -150,6 +153,7 @@ jobs: persist-credentials: false - name: Build and publish platform digest + id: platform uses: ./.github/actions/build-base-image-platform with: agent: ${{ matrix.agent }} @@ -177,6 +181,9 @@ jobs: - reviewed-npm-audit runs-on: ${{ matrix.runner }} timeout-minutes: 60 + outputs: + amd64-digest: ${{ steps.platform.outputs.amd64-digest }} + arm64-digest: ${{ steps.platform.outputs.arm64-digest }} strategy: fail-fast: false matrix: @@ -202,6 +209,7 @@ jobs: persist-credentials: false - name: Build and publish platform digest + id: platform uses: ./.github/actions/build-base-image-platform with: agent: ${{ matrix.agent }} @@ -221,6 +229,9 @@ jobs: - reviewed-npm-audit runs-on: ${{ matrix.runner }} timeout-minutes: 60 + outputs: + amd64-digest: ${{ steps.platform.outputs.amd64-digest }} + arm64-digest: ${{ steps.platform.outputs.arm64-digest }} strategy: fail-fast: false matrix: @@ -246,6 +257,7 @@ jobs: persist-credentials: false - name: Build and publish platform digest + id: platform uses: ./.github/actions/build-base-image-platform with: agent: ${{ matrix.agent }} @@ -268,6 +280,9 @@ jobs: - reviewed-npm-audit runs-on: ${{ matrix.runner }} timeout-minutes: 60 + outputs: + amd64-digest: ${{ steps.platform.outputs.amd64-digest }} + arm64-digest: ${{ steps.platform.outputs.arm64-digest }} strategy: fail-fast: false matrix: @@ -290,6 +305,7 @@ jobs: with: persist-credentials: false - name: Build and publish platform digest + id: platform uses: ./.github/actions/build-base-image-platform with: agent: ${{ matrix.agent }} @@ -308,15 +324,20 @@ jobs: - reviewed-npm-audit runs-on: ubuntu-latest timeout-minutes: 10 + outputs: + contract-base64: ${{ steps.publish.outputs.contract-base64 }} steps: - name: Checkout uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false - name: Publish validated multi-platform manifest + id: publish uses: ./.github/actions/publish-base-image-manifest with: agent: pi + amd64-digest: ${{ needs.build-pi-platforms.outputs.amd64-digest }} + arm64-digest: ${{ needs.build-pi-platforms.outputs.arm64-digest }} display-name: Pi image: nvidia/nemoclaw/pi-sandbox-base registry: ${{ env.REGISTRY }} @@ -330,6 +351,8 @@ jobs: - reviewed-npm-audit runs-on: ubuntu-latest timeout-minutes: 10 + outputs: + contract-base64: ${{ steps.publish.outputs.contract-base64 }} steps: - name: Checkout uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 @@ -337,9 +360,12 @@ jobs: persist-credentials: false - name: Publish validated multi-platform manifest + id: publish uses: ./.github/actions/publish-base-image-manifest with: agent: hermes + amd64-digest: ${{ needs.build-hermes-platforms.outputs.amd64-digest }} + arm64-digest: ${{ needs.build-hermes-platforms.outputs.arm64-digest }} display-name: Hermes image: nvidia/nemoclaw/hermes-sandbox-base registry: ${{ env.REGISTRY }} @@ -355,6 +381,8 @@ jobs: - reviewed-npm-audit runs-on: ubuntu-latest timeout-minutes: 10 + outputs: + contract-base64: ${{ steps.publish.outputs.contract-base64 }} steps: - name: Checkout uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 @@ -362,9 +390,12 @@ jobs: persist-credentials: false - name: Publish validated multi-platform manifest + id: publish uses: ./.github/actions/publish-base-image-manifest with: agent: langchain-deepagents-code + amd64-digest: ${{ needs.build-dcode-platforms.outputs.amd64-digest }} + arm64-digest: ${{ needs.build-dcode-platforms.outputs.arm64-digest }} display-name: Deep Agents Code image: nvidia/nemoclaw/langchain-deepagents-code-sandbox-base registry: ${{ env.REGISTRY }} @@ -382,6 +413,8 @@ jobs: - reviewed-npm-audit runs-on: ubuntu-latest timeout-minutes: 10 + outputs: + contract-base64: ${{ steps.publish.outputs.contract-base64 }} steps: - name: Checkout uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 @@ -389,9 +422,12 @@ jobs: persist-credentials: false - name: Publish validated multi-platform manifest + id: publish uses: ./.github/actions/publish-base-image-manifest with: agent: openclaw + amd64-digest: ${{ needs.build-openclaw-platforms.outputs.amd64-digest }} + arm64-digest: ${{ needs.build-openclaw-platforms.outputs.arm64-digest }} display-name: OpenClaw image: nvidia/nemoclaw/sandbox-base registry: ${{ env.REGISTRY }} @@ -417,3 +453,7 @@ jobs: contents: read packages: write uses: ./.github/workflows/managed-images.yaml + with: + dcode-base-contract-base64: ${{ needs.build-and-push-dcode.outputs.contract-base64 }} + hermes-base-contract-base64: ${{ needs.build-and-push-hermes.outputs.contract-base64 }} + openclaw-base-contract-base64: ${{ needs.build-and-push-openclaw.outputs.contract-base64 }} diff --git a/.github/workflows/managed-images.yaml b/.github/workflows/managed-images.yaml index 98c4b486ca5..b4c999b0d03 100644 --- a/.github/workflows/managed-images.yaml +++ b/.github/workflows/managed-images.yaml @@ -19,6 +19,16 @@ name: Images / Managed Images on: workflow_call: + inputs: + dcode-base-contract-base64: + required: true + type: string + hermes-base-contract-base64: + required: true + type: string + openclaw-base-contract-base64: + required: true + type: string pull_request: paths: - ".github/actions/ci-reviewed-npm-audit/**" @@ -1456,11 +1466,42 @@ jobs: env: *pi_candidate_env steps: *pi_candidate_steps + publication-identity: + name: Record managed image publication identity + if: github.event_name != 'pull_request' + runs-on: ubuntu-24.04 + timeout-minutes: 5 + outputs: + cohort: ${{ steps.identity.outputs.cohort }} + steps: + - name: Record publication identity + id: identity + shell: bash + run: | + set -euo pipefail + [[ "$GITHUB_RUN_ID" =~ ^[1-9][0-9]{0,19}$ ]] + [[ "$GITHUB_RUN_ATTEMPT" =~ ^[1-9][0-9]{0,9}$ ]] + printf 'cohort=ghrun-%s-%s\n' "$GITHUB_RUN_ID" "$GITHUB_RUN_ATTEMPT" >> "$GITHUB_OUTPUT" + build-and-validate: name: Build and validate ${{ matrix.display_name }} managed image (${{ matrix.arch }}) if: github.event_name != 'pull_request' + needs: publication-identity runs-on: ${{ matrix.runner }} timeout-minutes: 120 + outputs: + openclaw-linux-amd64: ${{ steps.candidate-output.outputs.openclaw_linux_amd64 }} + openclaw-linux-amd64-attempt: ${{ steps.candidate-output.outputs.openclaw_linux_amd64_attempt }} + openclaw-linux-arm64: ${{ steps.candidate-output.outputs.openclaw_linux_arm64 }} + openclaw-linux-arm64-attempt: ${{ steps.candidate-output.outputs.openclaw_linux_arm64_attempt }} + hermes-linux-amd64: ${{ steps.candidate-output.outputs.hermes_linux_amd64 }} + hermes-linux-amd64-attempt: ${{ steps.candidate-output.outputs.hermes_linux_amd64_attempt }} + hermes-linux-arm64: ${{ steps.candidate-output.outputs.hermes_linux_arm64 }} + hermes-linux-arm64-attempt: ${{ steps.candidate-output.outputs.hermes_linux_arm64_attempt }} + dcode-linux-amd64: ${{ steps.candidate-output.outputs.langchain_deepagents_code_linux_amd64 }} + dcode-linux-amd64-attempt: ${{ steps.candidate-output.outputs.langchain_deepagents_code_linux_amd64_attempt }} + dcode-linux-arm64: ${{ steps.candidate-output.outputs.langchain_deepagents_code_linux_arm64 }} + dcode-linux-arm64-attempt: ${{ steps.candidate-output.outputs.langchain_deepagents_code_linux_arm64_attempt }} strategy: fail-fast: false matrix: @@ -1534,11 +1575,33 @@ jobs: - 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-${{ github.run_id }}-${{ github.run_attempt }}-${{ matrix.agent }} - path: ${{ runner.temp }}/managed-base-contract + - name: Restore exact base image contract + shell: bash + env: + AGENT: ${{ matrix.agent }} + DCODE_CONTRACT_BASE64: ${{ inputs.dcode-base-contract-base64 }} + HERMES_CONTRACT_BASE64: ${{ inputs.hermes-base-contract-base64 }} + OPENCLAW_CONTRACT_BASE64: ${{ inputs.openclaw-base-contract-base64 }} + run: | + set -euo pipefail + case "$AGENT" in + openclaw) contract_base64="$OPENCLAW_CONTRACT_BASE64" ;; + hermes) contract_base64="$HERMES_CONTRACT_BASE64" ;; + langchain-deepagents-code) contract_base64="$DCODE_CONTRACT_BASE64" ;; + *) echo "ERROR: unsupported managed agent: $AGENT" >&2; exit 1 ;; + esac + if [ -z "$contract_base64" ] || [ "${#contract_base64}" -gt 131072 ]; then + echo "ERROR: exact base image producer output is missing or oversized." >&2 + exit 1 + fi + if [[ ! "$contract_base64" =~ ^[A-Za-z0-9+/]+={0,2}$ ]] || + [ $(( ${#contract_base64} % 4 )) -ne 0 ]; then + echo "ERROR: exact base image producer output is not canonical base64." >&2 + exit 1 + fi + contract_root="$RUNNER_TEMP/managed-base-contract" + install -d -m 0700 "$contract_root" + printf '%s' "$contract_base64" | base64 --decode > "$contract_root/contract.json" - name: Validate exact base image contract id: base @@ -1586,7 +1649,10 @@ jobs: and .platformReferences[$platform] == (.image + "@" + .platformDigests[$platform]) and .sourceRevision == $revision - and .run == {id: $runId, attempt: $runAttempt} + and .run.id == $runId + and (.run.attempt | type) == "number" + and .run.attempt >= 1 + and .run.attempt <= $runAttempt ' "$CONTRACT" >/dev/null then echo "ERROR: exact base image contract failed closed validation." >&2 @@ -1637,7 +1703,7 @@ jobs: io.nvidia.nemoclaw.managed-image.platform=${{ matrix.platform }} io.nvidia.nemoclaw.managed-image.startup-profile=1 io.nvidia.nemoclaw.managed-image.capabilities=1 - io.nvidia.nemoclaw.managed-image.cohort=ghrun-${{ github.run_id }}-${{ github.run_attempt }} + io.nvidia.nemoclaw.managed-image.cohort=${{ needs.publication-identity.outputs.cohort }} build-args: | BASE_IMAGE=${{ steps.base.outputs.ref }} NEMOCLAW_MANAGED_IMAGE_CAPABILITY_UNION=1 @@ -1660,7 +1726,7 @@ jobs: DIGEST: ${{ steps.build.outputs.digest }} IMAGE: ${{ env.REGISTRY }}/${{ matrix.image }} PLATFORM: ${{ matrix.platform }} - PUBLICATION_COHORT: ghrun-${{ github.run_id }}-${{ github.run_attempt }} + PUBLICATION_COHORT: ${{ needs.publication-identity.outputs.cohort }} REQUIRED_BINARY: ${{ matrix.required_binary }} run: | set -euo pipefail @@ -1876,7 +1942,7 @@ jobs: env: AGENT: ${{ matrix.agent }} BASE_REFERENCE: ${{ steps.base.outputs.ref }} - COHORT: ghrun-${{ github.run_id }}-${{ github.run_attempt }} + COHORT: ${{ needs.publication-identity.outputs.cohort }} DIGEST: ${{ steps.build.outputs.digest }} PLATFORM: ${{ matrix.platform }} REFERENCE: ${{ steps.validate.outputs.reference }} @@ -1906,7 +1972,7 @@ jobs: EVIDENCE: ${{ steps.evidence.outputs.path }} IMAGE: ${{ env.REGISTRY }}/${{ matrix.image }} PLATFORM: ${{ matrix.platform }} - PUBLICATION_COHORT: ghrun-${{ github.run_id }}-${{ github.run_attempt }} + PUBLICATION_COHORT: ${{ needs.publication-identity.outputs.cohort }} REFERENCE: ${{ steps.validate.outputs.reference }} run: | set -euo pipefail @@ -2052,21 +2118,87 @@ jobs: if-no-files-found: error retention-days: 1 + - name: Export validated managed image candidate + id: candidate-output + shell: bash + env: + AGENT: ${{ matrix.agent }} + ARTIFACT_PLATFORM: ${{ matrix.artifact_platform }} + run: | + set -euo pipefail + contract="$RUNNER_TEMP/managed-image-candidate/contract.json" + if [ ! -f "$contract" ] || [ -L "$contract" ]; then + echo "ERROR: managed image candidate contract is missing or unsafe." >&2 + exit 1 + fi + contract_size="$(wc -c < "$contract" | tr -d '[:space:]')" + if [[ ! "$contract_size" =~ ^[1-9][0-9]{0,6}$ ]] || [ "$contract_size" -gt 262144 ]; then + echo "ERROR: managed image candidate contract is empty or oversized." >&2 + exit 1 + fi + output_name="${AGENT//-/_}_${ARTIFACT_PLATFORM//-/_}" + printf '%s=%s\n' "$output_name" "$(base64 -w 0 "$contract")" >> "$GITHUB_OUTPUT" + printf '%s_attempt=%s\n' "$output_name" "$GITHUB_RUN_ATTEMPT" >> "$GITHUB_OUTPUT" + promote: name: Promote complete multi-platform managed image cohort - needs: build-and-validate + needs: + - publication-identity + - 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-${{ github.run_id }}-${{ github.run_attempt }}-* - path: ${{ runner.temp }}/managed-image-candidates - merge-multiple: false + - name: Restore all validated managed image candidates + shell: bash + env: + DCODE_AMD64: ${{ needs.build-and-validate.outputs.dcode-linux-amd64 }} + DCODE_AMD64_ATTEMPT: ${{ needs.build-and-validate.outputs.dcode-linux-amd64-attempt }} + DCODE_ARM64: ${{ needs.build-and-validate.outputs.dcode-linux-arm64 }} + DCODE_ARM64_ATTEMPT: ${{ needs.build-and-validate.outputs.dcode-linux-arm64-attempt }} + HERMES_AMD64: ${{ needs.build-and-validate.outputs.hermes-linux-amd64 }} + HERMES_AMD64_ATTEMPT: ${{ needs.build-and-validate.outputs.hermes-linux-amd64-attempt }} + HERMES_ARM64: ${{ needs.build-and-validate.outputs.hermes-linux-arm64 }} + HERMES_ARM64_ATTEMPT: ${{ needs.build-and-validate.outputs.hermes-linux-arm64-attempt }} + OPENCLAW_AMD64: ${{ needs.build-and-validate.outputs.openclaw-linux-amd64 }} + OPENCLAW_AMD64_ATTEMPT: ${{ needs.build-and-validate.outputs.openclaw-linux-amd64-attempt }} + OPENCLAW_ARM64: ${{ needs.build-and-validate.outputs.openclaw-linux-arm64 }} + OPENCLAW_ARM64_ATTEMPT: ${{ needs.build-and-validate.outputs.openclaw-linux-arm64-attempt }} + run: | + set -euo pipefail + candidate_root="$RUNNER_TEMP/managed-image-candidates" + install -d -m 0700 "$candidate_root" + restore_candidate() { + local agent="$1" + local platform="$2" + local producer_attempt="$3" + local contract_base64="$4" + if [[ ! "$producer_attempt" =~ ^[1-9][0-9]{0,9}$ ]]; then + echo "ERROR: managed image candidate producer attempt is invalid." >&2 + exit 1 + fi + if [ -z "$contract_base64" ] || [ "${#contract_base64}" -gt 524288 ]; then + echo "ERROR: managed image candidate producer output is missing or oversized." >&2 + exit 1 + fi + if [[ ! "$contract_base64" =~ ^[A-Za-z0-9+/]+={0,2}$ ]] || + [ $(( ${#contract_base64} % 4 )) -ne 0 ]; then + echo "ERROR: managed image candidate producer output is not canonical base64." >&2 + exit 1 + fi + local artifact="managed-image-candidate-${GITHUB_RUN_ID}-${producer_attempt}-${agent}-${platform}" + local artifact_root="$candidate_root/$artifact" + install -d -m 0700 "$artifact_root" + printf '%s' "$contract_base64" | base64 --decode > "$artifact_root/contract.json" + } + restore_candidate openclaw linux-amd64 "$OPENCLAW_AMD64_ATTEMPT" "$OPENCLAW_AMD64" + restore_candidate openclaw linux-arm64 "$OPENCLAW_ARM64_ATTEMPT" "$OPENCLAW_ARM64" + restore_candidate hermes linux-amd64 "$HERMES_AMD64_ATTEMPT" "$HERMES_AMD64" + restore_candidate hermes linux-arm64 "$HERMES_ARM64_ATTEMPT" "$HERMES_ARM64" + restore_candidate langchain-deepagents-code linux-amd64 "$DCODE_AMD64_ATTEMPT" "$DCODE_AMD64" + restore_candidate langchain-deepagents-code linux-arm64 "$DCODE_ARM64_ATTEMPT" "$DCODE_ARM64" # This is the all-agent/all-architecture publication barrier. It fails # closed before registry authentication or any alias operation. @@ -2075,13 +2207,20 @@ jobs: shell: bash env: CANDIDATE_ROOT: ${{ runner.temp }}/managed-image-candidates + DCODE_AMD64_ATTEMPT: ${{ needs.build-and-validate.outputs.dcode-linux-amd64-attempt }} + DCODE_ARM64_ATTEMPT: ${{ needs.build-and-validate.outputs.dcode-linux-arm64-attempt }} + HERMES_AMD64_ATTEMPT: ${{ needs.build-and-validate.outputs.hermes-linux-amd64-attempt }} + HERMES_ARM64_ATTEMPT: ${{ needs.build-and-validate.outputs.hermes-linux-arm64-attempt }} + OPENCLAW_AMD64_ATTEMPT: ${{ needs.build-and-validate.outputs.openclaw-linux-amd64-attempt }} + OPENCLAW_ARM64_ATTEMPT: ${{ needs.build-and-validate.outputs.openclaw-linux-arm64-attempt }} + PUBLICATION_COHORT: ${{ needs.publication-identity.outputs.cohort }} 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}" + expected_cohort="$PUBLICATION_COHORT" 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 @@ -2098,14 +2237,21 @@ jobs: exit 1 fi - artifact_prefix="managed-image-candidate-${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}" expected_artifacts=( - "${artifact_prefix}-openclaw-linux-amd64" - "${artifact_prefix}-openclaw-linux-arm64" - "${artifact_prefix}-hermes-linux-amd64" - "${artifact_prefix}-hermes-linux-arm64" - "${artifact_prefix}-langchain-deepagents-code-linux-amd64" - "${artifact_prefix}-langchain-deepagents-code-linux-arm64" + "managed-image-candidate-${GITHUB_RUN_ID}-${OPENCLAW_AMD64_ATTEMPT}-openclaw-linux-amd64" + "managed-image-candidate-${GITHUB_RUN_ID}-${OPENCLAW_ARM64_ATTEMPT}-openclaw-linux-arm64" + "managed-image-candidate-${GITHUB_RUN_ID}-${HERMES_AMD64_ATTEMPT}-hermes-linux-amd64" + "managed-image-candidate-${GITHUB_RUN_ID}-${HERMES_ARM64_ATTEMPT}-hermes-linux-arm64" + "managed-image-candidate-${GITHUB_RUN_ID}-${DCODE_AMD64_ATTEMPT}-langchain-deepagents-code-linux-amd64" + "managed-image-candidate-${GITHUB_RUN_ID}-${DCODE_ARM64_ATTEMPT}-langchain-deepagents-code-linux-arm64" + ) + expected_attempts=( + "$OPENCLAW_AMD64_ATTEMPT" + "$OPENCLAW_ARM64_ATTEMPT" + "$HERMES_AMD64_ATTEMPT" + "$HERMES_ARM64_ATTEMPT" + "$DCODE_AMD64_ATTEMPT" + "$DCODE_ARM64_ATTEMPT" ) expected_agents=( openclaw @@ -2141,6 +2287,7 @@ jobs: artifact="${expected_artifacts[$index]}" expected_agent="${expected_agents[$index]}" expected_platform="${expected_platforms[$index]}" + expected_attempt="${expected_attempts[$index]}" artifact_dir="$CANDIDATE_ROOT/$artifact" contract="$artifact_dir/contract.json" if [ ! -d "$artifact_dir" ] || [ -L "$artifact_dir" ] || @@ -2156,7 +2303,10 @@ jobs: ! jq -e \ --arg agent "$expected_agent" \ --arg platform "$expected_platform" \ - '.agent == $agent and .platform == $platform' \ + --argjson attempt "$expected_attempt" \ + --argjson runId "$GITHUB_RUN_ID" \ + '.agent == $agent and .platform == $platform and + .run == {id: $runId, attempt: $attempt}' \ "$contract" >/dev/null; then echo "ERROR: managed image candidate artifact identity is invalid: $artifact" >&2 exit 1 @@ -2166,7 +2316,25 @@ jobs: candidate_set="$RUNNER_TEMP/managed-image-candidate-set.json" jq -s 'sort_by(.agent, .platform)' "${candidate_files[@]}" > "$candidate_set" + expected_attempts_json="$( + jq -cn \ + --argjson openclawAmd64 "$OPENCLAW_AMD64_ATTEMPT" \ + --argjson openclawArm64 "$OPENCLAW_ARM64_ATTEMPT" \ + --argjson hermesAmd64 "$HERMES_AMD64_ATTEMPT" \ + --argjson hermesArm64 "$HERMES_ARM64_ATTEMPT" \ + --argjson dcodeAmd64 "$DCODE_AMD64_ATTEMPT" \ + --argjson dcodeArm64 "$DCODE_ARM64_ATTEMPT" \ + '{ + "openclaw|linux/amd64": $openclawAmd64, + "openclaw|linux/arm64": $openclawArm64, + "hermes|linux/amd64": $hermesAmd64, + "hermes|linux/arm64": $hermesArm64, + "langchain-deepagents-code|linux/amd64": $dcodeAmd64, + "langchain-deepagents-code|linux/arm64": $dcodeArm64 + }' + )" if ! jq -e \ + --argjson attempts "$expected_attempts_json" \ --arg ref "$GITHUB_REF" \ --arg release "$expected_release" \ --arg repository "$GITHUB_REPOSITORY" \ @@ -2310,7 +2478,8 @@ jobs: and .bindings.source == ("https://github.com/" + $repository) and .builderId == ("https://github.com/" + $repository + "/actions/runs/" + - ($runId | tostring) + "/attempts/" + ($runAttempt | tostring)) + ($runId | tostring) + "/attempts/" + + ($attempts[$candidateAgent + "|" + $candidatePlatform] | tostring)) ) and (.publicationEvidence.attestations.spdx | keys | sort) == [ "descriptor", @@ -2339,7 +2508,10 @@ jobs: ref: $ref, cohort: $cohort } - and .run == {id: $runId, attempt: $runAttempt} + and .run == { + id: $runId, + attempt: $attempts[.agent + "|" + .platform] + } and .release == (if $release == "" then null else $release end) and (has("aliases") | not) ) @@ -2480,11 +2652,12 @@ jobs: shell: bash env: CANDIDATE_SET: ${{ steps.candidates.outputs.candidate_set }} + PUBLICATION_COHORT: ${{ needs.publication-identity.outputs.cohort }} run: | set -euo pipefail contract_root="$RUNNER_TEMP/managed-image-contracts" mkdir -p "$contract_root" - cohort="ghrun-${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}" + cohort="$PUBLICATION_COHORT" 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 @@ -2892,6 +3065,8 @@ jobs: # consumer root pointing at a cohort without its durable v2 contracts. - name: Promote durable managed image cohort pointers shell: bash + env: + PUBLICATION_COHORT: ${{ needs.publication-identity.outputs.cohort }} run: | set -euo pipefail cohort_contract="$RUNNER_TEMP/managed-image-contracts/cohort.json" @@ -2899,7 +3074,7 @@ jobs: echo "ERROR: durable managed image cohort contract is missing or unsafe." >&2 exit 1 fi - cohort="ghrun-${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}" + cohort="$PUBLICATION_COHORT" if ! jq -e \ --arg cohort "$cohort" \ --arg repository "$GITHUB_REPOSITORY" \ diff --git a/ci/source-shape-test-budget.json b/ci/source-shape-test-budget.json index fe4b3ede951..5187d386bce 100644 --- a/ci/source-shape-test-budget.json +++ b/ci/source-shape-test-budget.json @@ -78,7 +78,7 @@ }, { "file": "test/managed-image-rerun-artifacts.test.ts", - "test": "retains producer artifact identities during a failed-job rerun (#9529)", + "test": "retains exact producer outputs when successful jobs are reused on a failed-job rerun (#9529)", "category": "security" }, { diff --git a/test/helpers/managed-image-publication-barrier.ts b/test/helpers/managed-image-publication-barrier.ts index a3c3db11f63..77e76a0e8c9 100644 --- a/test/helpers/managed-image-publication-barrier.ts +++ b/test/helpers/managed-image-publication-barrier.ts @@ -24,6 +24,11 @@ type Candidate = { export type CandidateMutation = (candidates: Candidate[]) => Candidate[]; +type BarrierOptions = { + expectedAttempts?: Partial>; + publicationCohort?: string; +}; + type PromotionResult = { calls: string[]; cohortContract: Record | null; @@ -157,6 +162,7 @@ export function runPublicationBarrier( script: string, mutate: CandidateMutation = (value) => value, afterBarrier = "", + options: BarrierOptions = {}, ): { dockerCalls: string[]; status: number | null; @@ -198,7 +204,16 @@ export function runPublicationBarrier( GITHUB_RUN_ATTEMPT: runAttempt, GITHUB_RUN_ID: runId, GITHUB_SHA: revision, + DCODE_AMD64_ATTEMPT: + options.expectedAttempts?.["langchain-deepagents-code|linux/amd64"] ?? runAttempt, + DCODE_ARM64_ATTEMPT: + options.expectedAttempts?.["langchain-deepagents-code|linux/arm64"] ?? runAttempt, + HERMES_AMD64_ATTEMPT: options.expectedAttempts?.["hermes|linux/amd64"] ?? runAttempt, + HERMES_ARM64_ATTEMPT: options.expectedAttempts?.["hermes|linux/arm64"] ?? runAttempt, + OPENCLAW_AMD64_ATTEMPT: options.expectedAttempts?.["openclaw|linux/amd64"] ?? runAttempt, + OPENCLAW_ARM64_ATTEMPT: options.expectedAttempts?.["openclaw|linux/arm64"] ?? runAttempt, PATH: `${bin}:${process.env.PATH ?? ""}`, + PUBLICATION_COHORT: options.publicationCohort ?? cohort, RUNNER_TEMP: root, }, }); @@ -302,6 +317,7 @@ fi GITHUB_RUN_ID: runId, GITHUB_SHA: revision, PATH: `${bin}:${process.env.PATH ?? ""}`, + PUBLICATION_COHORT: cohort, RUNNER_TEMP: root, STATE_ROOT: root, }, diff --git a/test/helpers/managed-image-publication-workflow-types.ts b/test/helpers/managed-image-publication-workflow-types.ts index 39ea6c0dde1..7f805cebeb0 100644 --- a/test/helpers/managed-image-publication-workflow-types.ts +++ b/test/helpers/managed-image-publication-workflow-types.ts @@ -33,6 +33,7 @@ export type Job = { env?: Record; if?: string; needs?: string | string[]; + outputs?: Record; permissions?: Record; "runs-on"?: string; steps?: Step[]; @@ -42,6 +43,7 @@ export type Job = { }; "timeout-minutes"?: number; uses?: string; + with?: Record; }; export type Workflow = { @@ -59,7 +61,9 @@ export type Workflow = { push?: { paths?: string[]; }; - workflow_call?: unknown; + workflow_call?: { + inputs?: Record; + }; }; permissions?: Record; }; diff --git a/test/managed-image-publication-workflow.test.ts b/test/managed-image-publication-workflow.test.ts index 862047b40ae..db2e5cb82c8 100644 --- a/test/managed-image-publication-workflow.test.ts +++ b/test/managed-image-publication-workflow.test.ts @@ -224,7 +224,8 @@ function publicationBoundaryErrors(baseWorkflow: Workflow, managedWorkflow: Work : ["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}") + base.run.includes(".run.id == $runId") && + base.run.includes(".run.attempt <= $runAttempt") ? [] : ["managed image build must consume the same-run exact base digest contract"]), ...validationMarkers @@ -236,7 +237,8 @@ function publicationBoundaryErrors(baseWorkflow: Workflow, managedWorkflow: Work ...(buildIndex >= 0 && buildIndex < validateIndex ? [] : ["managed image validation must follow its immutable digest build"]), - ...(promoter.needs === "build-and-validate" + ...(JSON.stringify(promoter.needs) === + JSON.stringify(["publication-identity", "build-and-validate"]) ? [] : ["aggregate promotion must require every matrix lane"]), ]; @@ -714,9 +716,11 @@ describe("complete managed-image publication workflow", () => { path: "staging-qa-base-source", "persist-credentials": false, }); - steps.filter((candidate) => candidate.uses).forEach((action) => { - expect(action.uses, action.name).toMatch(fullShaAction); - }); + steps + .filter((candidate) => candidate.uses) + .forEach((action) => { + expect(action.uses, action.name).toMatch(fullShaAction); + }); expect(drift.run).toBe( step(managedPrBuilder(workflow), "Reproduce reviewed discovery permission drift").run, ); @@ -795,7 +799,9 @@ describe("complete managed-image publication workflow", () => { expect(step(activation, "Checkout exact PR head").with?.ref).toBe( "${{ github.event.pull_request.head.sha }}", ); - expect(step(activation, "Assemble exact all-agent activation catalog").run).toMatch(/npm ci --ignore-scripts[\s\S]*pr-managed-image-publication\.mts assemble[\s\S]*"\$CANDIDATE_SHA"[\s\S]*"\$\{contracts\[@\]\}"/u); + expect(step(activation, "Assemble exact all-agent activation catalog").run).toMatch( + /npm ci --ignore-scripts[\s\S]*pr-managed-image-publication\.mts assemble[\s\S]*"\$CANDIDATE_SHA"[\s\S]*"\$\{contracts\[@\]\}"/u, + ); expect(step(activation, "Build exact candidate CLI").run).toContain("npm run build:cli"); expect(step(activation, "Install OpenShell CLI").run).toContain("scripts/install-openshell.sh"); const run = step(activation, "Run real all-agent managed runtime activation").run ?? ""; @@ -831,7 +837,9 @@ describe("complete managed-image publication workflow", () => { ); expect(step(discovery, "Bind E2E correlation identity").run).toContain("randomUUID()"); const assemble = step(discovery, "Assemble exact all-agent MCP catalog").run ?? ""; - expect(assemble).toMatch(/npm ci --ignore-scripts[\s\S]*pr-managed-image-publication\.mts assemble[\s\S]*"\$CANDIDATE_SHA"[\s\S]*"\$\{contracts\[@\]\}"/u); + expect(assemble).toMatch( + /npm ci --ignore-scripts[\s\S]*pr-managed-image-publication\.mts assemble[\s\S]*"\$CANDIDATE_SHA"[\s\S]*"\$\{contracts\[@\]\}"/u, + ); const run = step(discovery, "Run exact OpenClaw trusted-private MCP discovery").run ?? ""; expect(run).toContain('[[ "$(git rev-parse --verify HEAD)" == "$CANDIDATE_SHA" ]]'); expect(JSON.stringify(discovery)).not.toContain("jq "); @@ -1062,16 +1070,15 @@ fi const promoter = managedPromoter(workflow); const steps = publisher.steps ?? []; - [...steps, ...(promoter.steps ?? [])].filter( - (candidate) => candidate.uses, - ).forEach((action) => { - expect(action.uses, action.name).toMatch(fullShaAction); - }); + [...steps, ...(promoter.steps ?? [])] + .filter((candidate) => candidate.uses) + .forEach((action) => { + 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-${{ github.run_id }}-${{ github.run_attempt }}-${{ matrix.agent }}", - path: "${{ runner.temp }}/managed-base-contract", - }); + expect(step(publisher, "Restore exact base image contract").run).toContain( + 'base64 --decode > "$contract_root/contract.json"', + ); const guard = step(publisher, "Validate production build args"); const build = step(publisher, "Build and push managed image by digest"); @@ -1095,7 +1102,7 @@ fi 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 }}", + "io.nvidia.nemoclaw.managed-image.cohort=${{ needs.publication-identity.outputs.cohort }}", ); const base = step(publisher, "Validate exact base image contract"); @@ -1103,22 +1110,24 @@ fi expect(base.run).toContain('imagetools inspect "$platform_reference"'); const contract = step(publisher, "Export validated managed image candidate"); - expect([ - "--arg baseReference", - "--arg digest", - "--arg platform", - "--arg cohort", - "--arg revision", - "--arg cohort", - "--argjson runAttempt", - "--argjson runId", - "contractVersion: 2", - 'phase: "candidate"', - "--slurpfile publicationEvidence", - "publicationEvidence: $publicationEvidence[0]", - "https://slsa.dev/provenance/v1", - "https://spdx.dev/Document", - ].every((marker) => contract.run?.includes(marker) === true)).toBe(true); + expect( + [ + "--arg baseReference", + "--arg digest", + "--arg platform", + "--arg cohort", + "--arg revision", + "--arg cohort", + "--argjson runAttempt", + "--argjson runId", + "contractVersion: 2", + 'phase: "candidate"', + "--slurpfile publicationEvidence", + "publicationEvidence: $publicationEvidence[0]", + "https://slsa.dev/provenance/v1", + "https://spdx.dev/Document", + ].every((marker) => contract.run?.includes(marker) === true), + ).toBe(true); expect(step(publisher, "Upload validated managed image candidate").with).toMatchObject({ name: "managed-image-candidate-${{ github.run_id }}-${{ github.run_attempt }}-${{ matrix.agent }}-${{ matrix.artifact_platform }}", path: "${{ runner.temp }}/managed-image-candidate/contract.json", @@ -1186,12 +1195,10 @@ fi String(candidate.with?.name ?? "").startsWith("managed-image-"), ); - expect(promoter.needs).toBe("build-and-validate"); - expect(step(promoter, "Download all validated managed image candidates").with).toEqual({ - pattern: "managed-image-candidate-${{ github.run_id }}-${{ github.run_attempt }}-*", - path: "${{ runner.temp }}/managed-image-candidates", - "merge-multiple": false, - }); + expect(promoter.needs).toEqual(["publication-identity", "build-and-validate"]); + expect(step(promoter, "Restore all validated managed image candidates").run).toContain( + "restore_candidate()", + ); 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"]'); @@ -1468,33 +1475,4 @@ fi }, }); }); - - 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-${{ github.run_id }}-${{ github.run_attempt }}", - 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.replaceAll("/", "-"); - return { - name: - "managed-image-${{ github.run_id }}-${{ github.run_attempt }}-" + - `${agent}-${artifactPlatform}`, - path: `\${{ runner.temp }}/managed-image-contracts/${agent}/${artifactPlatform}/contract.json`, - "if-no-files-found": "error", - "retention-days": 90, - }; - }), - ), - ]); - }); }); diff --git a/test/managed-image-rerun-artifacts.test.ts b/test/managed-image-rerun-artifacts.test.ts index db8dd4bd96b..0120dd5d955 100644 --- a/test/managed-image-rerun-artifacts.test.ts +++ b/test/managed-image-rerun-artifacts.test.ts @@ -1,19 +1,39 @@ // 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"; import { describe, expect, it } from "vitest"; import YAML from "yaml"; +import { + publicationAgents, + publicationPlatforms, + runPublicationBarrier, +} from "./helpers/managed-image-publication-barrier"; + type Step = { + env?: Record; + id?: string; name?: string; + run?: string; + uses?: string; with?: Record; }; type Workflow = { - jobs?: Record; + jobs?: Record< + string, + { + needs?: string | string[]; + outputs?: Record; + steps?: Step[]; + with?: Record; + } + >; }; const repoRoot = path.resolve(import.meta.dirname, ".."); @@ -31,46 +51,174 @@ function requiredStep(steps: Step[] | undefined, name: string): Step { ); } -function renderArtifactIdentity(value: unknown, runAttempt: number): string { - return String(value) - .replaceAll("${{ github.run_id }}", "32191102997") - .replaceAll("${{ github.run_attempt }}", String(runAttempt)) - .replaceAll("${{ inputs.agent }}", "openclaw") - .replaceAll("${{ matrix.agent }}", "openclaw") - .replaceAll("${{ matrix.artifact_platform }}", "linux-amd64"); +function runCandidateRestore( + script: string, + overrides: Record = {}, +): { files: string[]; status: number | null; stderr: string } { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-managed-restore-")); + const contract = Buffer.from("{}\n").toString("base64"); + try { + const result = spawnSync("bash", ["-c", script], { + encoding: "utf8", + env: { + ...process.env, + DCODE_AMD64: contract, + DCODE_AMD64_ATTEMPT: "1", + DCODE_ARM64: contract, + DCODE_ARM64_ATTEMPT: "2", + GITHUB_RUN_ID: "7744", + HERMES_AMD64: contract, + HERMES_AMD64_ATTEMPT: "1", + HERMES_ARM64: contract, + HERMES_ARM64_ATTEMPT: "2", + OPENCLAW_AMD64: contract, + OPENCLAW_AMD64_ATTEMPT: "1", + OPENCLAW_ARM64: contract, + OPENCLAW_ARM64_ATTEMPT: "2", + RUNNER_TEMP: root, + ...overrides, + }, + }); + const candidateRoot = path.join(root, "managed-image-candidates"); + return { + files: fs.existsSync(candidateRoot) + ? fs.readdirSync(candidateRoot, { recursive: true }).map(String).sort() + : [], + status: result.status, + stderr: result.stderr, + }; + } finally { + fs.rmSync(root, { recursive: true, force: true }); + } } describe("managed-image failed-job rerun artifacts", () => { // source-shape-contract: security -- Producer artifact identity must remain exact when GitHub reuses a successful job during a failed-job rerun - it("retains producer artifact identities during a failed-job rerun (#9529)", () => { - const baseAction = readYaml( - ".github/actions/publish-base-image-manifest/action.yaml", - ) as Workflow & { runs?: { steps?: Step[] } }; - const workflow = readYaml(".github/workflows/managed-images.yaml"); - const publisher = workflow.jobs?.["build-and-validate"]; - const promoter = workflow.jobs?.promote; - const baseUpload = requiredStep(baseAction.runs?.steps, "Upload managed base image contract"); - const baseDownload = requiredStep(publisher?.steps, "Download exact base image contract"); - const candidateUpload = requiredStep( - publisher?.steps, - "Upload validated managed image candidate", + it("retains exact producer outputs when successful jobs are reused on a failed-job rerun (#9529)", () => { + const baseWorkflow = readYaml(".github/workflows/base-image.yaml"); + const managedWorkflow = readYaml(".github/workflows/managed-images.yaml"); + const platformProducer = baseWorkflow.jobs?.["build-openclaw-platforms"]; + const baseProducer = baseWorkflow.jobs?.["build-and-push-openclaw"]; + const managedCaller = baseWorkflow.jobs?.["publish-managed-images"]; + const identity = managedWorkflow.jobs?.["publication-identity"]; + const publisher = managedWorkflow.jobs?.["build-and-validate"]; + const promoter = managedWorkflow.jobs?.promote; + + expect(platformProducer?.outputs).toEqual({ + "amd64-digest": "${{ steps.platform.outputs.amd64-digest }}", + "arm64-digest": "${{ steps.platform.outputs.arm64-digest }}", + }); + expect( + requiredStep(baseProducer?.steps, "Publish validated multi-platform manifest").with, + ).toMatchObject({ + "amd64-digest": "${{ needs.build-openclaw-platforms.outputs.amd64-digest }}", + "arm64-digest": "${{ needs.build-openclaw-platforms.outputs.arm64-digest }}", + }); + expect(baseProducer?.outputs?.["contract-base64"]).toBe( + "${{ steps.publish.outputs.contract-base64 }}", ); - const candidateDownload = requiredStep( + expect(managedCaller?.with?.["openclaw-base-contract-base64"]).toBe( + "${{ needs.build-and-push-openclaw.outputs.contract-base64 }}", + ); + expect( + requiredStep(publisher?.steps, "Restore exact base image contract").env + ?.OPENCLAW_CONTRACT_BASE64, + ).toBe("${{ inputs.openclaw-base-contract-base64 }}"); + + expect(identity?.outputs).toEqual({ + cohort: "${{ steps.identity.outputs.cohort }}", + }); + expect(publisher?.needs).toBe("publication-identity"); + expect(publisher?.outputs?.["openclaw-linux-amd64"]).toBe( + "${{ steps.candidate-output.outputs.openclaw_linux_amd64 }}", + ); + expect(publisher?.outputs?.["openclaw-linux-amd64-attempt"]).toBe( + "${{ steps.candidate-output.outputs.openclaw_linux_amd64_attempt }}", + ); + const restoreCandidates = requiredStep( promoter?.steps, - "Download all validated managed image candidates", + "Restore all validated managed image candidates", ); + expect(restoreCandidates.env).toMatchObject({ + OPENCLAW_AMD64: "${{ needs.build-and-validate.outputs.openclaw-linux-amd64 }}", + OPENCLAW_AMD64_ATTEMPT: + "${{ needs.build-and-validate.outputs.openclaw-linux-amd64-attempt }}", + }); + expect(promoter?.needs).toEqual(["publication-identity", "build-and-validate"]); + expect( + requiredStep(promoter?.steps, "Validate complete managed image candidate set").env + ?.PUBLICATION_COHORT, + ).toBe("${{ needs.publication-identity.outputs.cohort }}"); + + const acceptedRestore = runCandidateRestore(restoreCandidates.run ?? ""); + expect(acceptedRestore.status).toBe(0); + expect(acceptedRestore.files.filter((file) => file.endsWith("contract.json"))).toHaveLength(6); + const invalidAttempt = runCandidateRestore(restoreCandidates.run ?? "", { + OPENCLAW_AMD64_ATTEMPT: "0", + }); + expect(invalidAttempt.status).not.toBe(0); + expect(invalidAttempt.stderr).toContain("producer attempt is invalid"); + const malformedContract = runCandidateRestore(restoreCandidates.run ?? "", { + OPENCLAW_AMD64: "not-base64", + }); + expect(malformedContract.status).not.toBe(0); + expect(malformedContract.stderr).not.toContain("not-base64"); - const producerBaseName = renderArtifactIdentity(baseUpload.with?.name, 1); - const rerunBaseName = renderArtifactIdentity(baseDownload.with?.name, 2); - const producerCandidateName = renderArtifactIdentity(candidateUpload.with?.name, 1); - const rerunCandidatePrefix = renderArtifactIdentity(candidateDownload.with?.pattern, 2).replace( - /\*$/u, + const barrier = requiredStep(promoter?.steps, "Validate complete managed image candidate set"); + const reusedKey = "openclaw|linux/amd64"; + const mixedAttempts = runPublicationBarrier( + barrier.run ?? "", + (candidateSet) => + candidateSet.map((candidate) => { + const contract = structuredClone(candidate.contract); + const producerAttempt = `${candidate.agent}|${candidate.platform}` === reusedKey ? 1 : 2; + (contract.source as Record).cohort = "ghrun-7744-1"; + (contract.run as Record).attempt = producerAttempt; + const evidence = contract.publicationEvidence as Record; + const attestations = evidence.attestations as Record; + const statement = (attestations.slsa as Record).statement as Record< + string, + unknown + >; + statement.builderId = `https://github.com/NVIDIA/NemoClaw/actions/runs/7744/attempts/${producerAttempt}`; + (statement.bindings as Record).cohort = "ghrun-7744-1"; + return { + ...candidate, + artifact: candidate.artifact.replace("-7744-2-", `-7744-${producerAttempt}-`), + contract, + }; + }), "", + { + expectedAttempts: { [reusedKey]: "1" }, + publicationCohort: "ghrun-7744-1", + }, ); + expect(mixedAttempts.status).toBe(0); - expect([ - rerunBaseName === producerBaseName, - producerCandidateName.startsWith(rerunCandidatePrefix), - ]).toEqual([true, true]); + const durableUploads = (promoter?.steps ?? []) + .filter((candidate) => candidate.uses?.startsWith("actions/upload-artifact@")) + .map((candidate) => candidate.with); + expect(durableUploads).toEqual([ + { + name: "managed-image-cohort-${{ github.run_id }}-${{ github.run_attempt }}", + 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.replaceAll("/", "-"); + return { + name: + "managed-image-${{ github.run_id }}-${{ github.run_attempt }}-" + + `${agent}-${artifactPlatform}`, + path: `\${{ runner.temp }}/managed-image-contracts/${agent}/${artifactPlatform}/contract.json`, + "if-no-files-found": "error", + "retention-days": 90, + }; + }), + ), + ]); }); }); From b1d0436c4b0f5090f1499ab310ad8351b796665f Mon Sep 17 00:00:00 2001 From: Julie Yaunches Date: Tue, 18 Aug 2026 22:40:09 -0400 Subject: [PATCH 03/12] fix(ci): preserve managed image producer attempts Signed-off-by: Julie Yaunches --- .github/workflows/managed-images.yaml | 29 +++-- .../managed-image-publication-barrier.ts | 11 +- ...managed-image-publication-workflow.test.ts | 37 ++++--- test/managed-image-rerun-artifacts.test.ts | 101 ++++++++++-------- 4 files changed, 106 insertions(+), 72 deletions(-) diff --git a/.github/workflows/managed-images.yaml b/.github/workflows/managed-images.yaml index b4c999b0d03..3b25b5c1b85 100644 --- a/.github/workflows/managed-images.yaml +++ b/.github/workflows/managed-images.yaml @@ -1791,7 +1791,13 @@ jobs: --format '{{index .Config.Labels "org.opencontainers.image.revision"}}' \ "$reference" )" - if [[ ! "$PUBLICATION_COHORT" =~ ^ghrun-[1-9][0-9]{0,19}-[1-9][0-9]{0,9}$ ]] || + cohort_prefix="ghrun-${GITHUB_RUN_ID}-" + cohort_attempt="${PUBLICATION_COHORT#"$cohort_prefix"}" + if [[ ! "$GITHUB_RUN_ID" =~ ^[1-9][0-9]{0,19}$ ]] || + [[ ! "$GITHUB_RUN_ATTEMPT" =~ ^[1-9][0-9]{0,9}$ ]] || + [[ "$PUBLICATION_COHORT" != "${cohort_prefix}"* ]] || + [[ ! "$cohort_attempt" =~ ^[1-9][0-9]{0,9}$ ]] || + [ "$cohort_attempt" -gt "$GITHUB_RUN_ATTEMPT" ] || [ "$agent_label" != "$AGENT" ] || [ "$contract_label" != "1" ] || [ "$startup_profile_label" != "1" ] || @@ -2118,7 +2124,7 @@ jobs: if-no-files-found: error retention-days: 1 - - name: Export validated managed image candidate + - name: Export validated managed image candidate output id: candidate-output shell: bash env: @@ -2175,7 +2181,9 @@ jobs: local platform="$2" local producer_attempt="$3" local contract_base64="$4" - if [[ ! "$producer_attempt" =~ ^[1-9][0-9]{0,9}$ ]]; then + if [[ ! "$producer_attempt" =~ ^[1-9][0-9]{0,9}$ ]] || + [[ ! "$GITHUB_RUN_ATTEMPT" =~ ^[1-9][0-9]{0,9}$ ]] || + [ "$producer_attempt" -gt "$GITHUB_RUN_ATTEMPT" ]; then echo "ERROR: managed image candidate producer attempt is invalid." >&2 exit 1 fi @@ -2221,7 +2229,13 @@ jobs: exit 1 fi expected_cohort="$PUBLICATION_COHORT" - if [[ ! "$expected_cohort" =~ ^ghrun-[1-9][0-9]{0,19}-[1-9][0-9]{0,9}$ ]]; then + expected_cohort_prefix="ghrun-${GITHUB_RUN_ID}-" + cohort_attempt="${expected_cohort#"$expected_cohort_prefix"}" + if [[ ! "$GITHUB_RUN_ID" =~ ^[1-9][0-9]{0,19}$ ]] || + [[ ! "$GITHUB_RUN_ATTEMPT" =~ ^[1-9][0-9]{0,9}$ ]] || + [[ "$expected_cohort" != "${expected_cohort_prefix}"* ]] || + [[ ! "$cohort_attempt" =~ ^[1-9][0-9]{0,9}$ ]] || + [ "$cohort_attempt" -gt "$GITHUB_RUN_ATTEMPT" ]; then echo "ERROR: publication cohort is invalid: $expected_cohort" >&2 exit 1 fi @@ -2851,6 +2865,7 @@ jobs: while IFS= read -r candidate; do agent="$(jq -r '.agent' <<<"$candidate")" platform="$(jq -r '.platform' <<<"$candidate")" + candidate_run="$(jq -ce '.run' <<<"$candidate")" artifact_platform="${platform//\//-}" cohort_manifest="$(jq -ce --arg agent "$agent" '.[] | select(.agent == $agent)' "$cohort_manifests")" cohort_alias="$(jq -r '.alias' <<<"$cohort_manifest")" @@ -2891,6 +2906,7 @@ jobs: --arg platform "$platform" \ --arg cohort "$cohort" \ --argjson aliases "$aliases_json" \ + --argjson candidateRun "$candidate_run" \ '(keys | sort) == [ "agent", "aliases", @@ -2927,10 +2943,7 @@ jobs: and .stagedCohort.descriptor.size > 0 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 .run == $candidateRun and .aliases == $aliases and ( (.image + ":cohort-" + $cohort) as $cohortAlias diff --git a/test/helpers/managed-image-publication-barrier.ts b/test/helpers/managed-image-publication-barrier.ts index 77e76a0e8c9..9872513a84a 100644 --- a/test/helpers/managed-image-publication-barrier.ts +++ b/test/helpers/managed-image-publication-barrier.ts @@ -37,6 +37,11 @@ type PromotionResult = { stderr: string; }; +type PromotionOptions = { + mutate?: CandidateMutation; + publicationCohort?: string; +}; + function imageFor(agent: (typeof publicationAgents)[number]): string { return `ghcr.io/nvidia/nemoclaw/${agent}-sandbox`; } @@ -234,6 +239,7 @@ export function runManagedImagePromotion( script: string, failCohortAgent = "", pointerScript = "", + options: PromotionOptions = {}, ): PromotionResult { const root = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-managed-promotion-")); const bin = path.join(root, "bin"); @@ -298,9 +304,10 @@ fi `, ); fs.chmodSync(path.join(bin, "docker"), 0o755); + const candidateValues = options.mutate ? options.mutate(candidates()) : candidates(); fs.writeFileSync( candidateSet, - `${JSON.stringify(candidates().map(({ contract }) => contract))}\n`, + `${JSON.stringify(candidateValues.map(({ contract }) => contract))}\n`, ); try { @@ -317,7 +324,7 @@ fi GITHUB_RUN_ID: runId, GITHUB_SHA: revision, PATH: `${bin}:${process.env.PATH ?? ""}`, - PUBLICATION_COHORT: cohort, + PUBLICATION_COHORT: options.publicationCohort ?? cohort, RUNNER_TEMP: root, STATE_ROOT: root, }, diff --git a/test/managed-image-publication-workflow.test.ts b/test/managed-image-publication-workflow.test.ts index db2e5cb82c8..90820569323 100644 --- a/test/managed-image-publication-workflow.test.ts +++ b/test/managed-image-publication-workflow.test.ts @@ -162,7 +162,8 @@ function publicationBoundaryErrors(baseWorkflow: Workflow, managedWorkflow: Work "io.nvidia.nemoclaw.managed-image.startup-profile", "io.nvidia.nemoclaw.managed-image.capabilities", "io.nvidia.nemoclaw.managed-image.cohort", - "^ghrun-[1-9][0-9]{0,19}-[1-9][0-9]{0,9}$", + 'cohort_prefix="ghrun-${GITHUB_RUN_ID}-"', + '[ "$cohort_attempt" -gt "$GITHUB_RUN_ATTEMPT" ]', "NEMOCLAW_MANAGED_IMAGE_CAPABILITY_UNION", "@openclaw/diagnostics-otel", "@openclaw/brave-plugin", @@ -1110,24 +1111,22 @@ fi expect(base.run).toContain('imagetools inspect "$platform_reference"'); const contract = step(publisher, "Export validated managed image candidate"); - expect( - [ - "--arg baseReference", - "--arg digest", - "--arg platform", - "--arg cohort", - "--arg revision", - "--arg cohort", - "--argjson runAttempt", - "--argjson runId", - "contractVersion: 2", - 'phase: "candidate"', - "--slurpfile publicationEvidence", - "publicationEvidence: $publicationEvidence[0]", - "https://slsa.dev/provenance/v1", - "https://spdx.dev/Document", - ].every((marker) => contract.run?.includes(marker) === true), - ).toBe(true); + const contractMarkers = [ + "--arg baseReference", + "--arg digest", + "--arg platform", + "--arg cohort", + "--arg revision", + "--argjson runAttempt", + "--argjson runId", + "contractVersion: 2", + 'phase: "candidate"', + "--slurpfile publicationEvidence", + "publicationEvidence: $publicationEvidence[0]", + "https://slsa.dev/provenance/v1", + "https://spdx.dev/Document", + ]; + expect(contractMarkers.filter((marker) => contract.run?.includes(marker) !== true)).toEqual([]); expect(step(publisher, "Upload validated managed image candidate").with).toMatchObject({ name: "managed-image-candidate-${{ github.run_id }}-${{ github.run_attempt }}-${{ matrix.agent }}-${{ matrix.artifact_platform }}", path: "${{ runner.temp }}/managed-image-candidate/contract.json", diff --git a/test/managed-image-rerun-artifacts.test.ts b/test/managed-image-rerun-artifacts.test.ts index 0120dd5d955..20a8a521aa3 100644 --- a/test/managed-image-rerun-artifacts.test.ts +++ b/test/managed-image-rerun-artifacts.test.ts @@ -10,31 +10,13 @@ import { describe, expect, it } from "vitest"; import YAML from "yaml"; import { + type CandidateMutation, publicationAgents, publicationPlatforms, runPublicationBarrier, + runManagedImagePromotion, } from "./helpers/managed-image-publication-barrier"; - -type Step = { - env?: Record; - id?: string; - name?: string; - run?: string; - uses?: string; - with?: Record; -}; - -type Workflow = { - jobs?: Record< - string, - { - needs?: string | string[]; - outputs?: Record; - steps?: Step[]; - with?: Record; - } - >; -}; +import type { Job, Step, Workflow } from "./helpers/managed-image-publication-workflow-types"; const repoRoot = path.resolve(import.meta.dirname, ".."); @@ -51,6 +33,28 @@ function requiredStep(steps: Step[] | undefined, name: string): Step { ); } +const reuseOpenclawAmd64FromAttemptOne: CandidateMutation = (candidateSet) => + candidateSet.map((candidate) => { + const contract = structuredClone(candidate.contract); + const producerAttempt = + `${candidate.agent}|${candidate.platform}` === "openclaw|linux/amd64" ? 1 : 2; + (contract.source as Record).cohort = "ghrun-7744-1"; + (contract.run as Record).attempt = producerAttempt; + const evidence = contract.publicationEvidence as Record; + const attestations = evidence.attestations as Record; + const statement = (attestations.slsa as Record).statement as Record< + string, + unknown + >; + statement.builderId = `https://github.com/NVIDIA/NemoClaw/actions/runs/7744/attempts/${producerAttempt}`; + (statement.bindings as Record).cohort = "ghrun-7744-1"; + return { + ...candidate, + artifact: candidate.artifact.replace("-7744-2-", `-7744-${producerAttempt}-`), + contract, + }; + }); + function runCandidateRestore( script: string, overrides: Record = {}, @@ -67,6 +71,7 @@ function runCandidateRestore( DCODE_ARM64: contract, DCODE_ARM64_ATTEMPT: "2", GITHUB_RUN_ID: "7744", + GITHUB_RUN_ATTEMPT: "2", HERMES_AMD64: contract, HERMES_AMD64_ATTEMPT: "1", HERMES_ARM64: contract, @@ -101,8 +106,8 @@ describe("managed-image failed-job rerun artifacts", () => { const baseProducer = baseWorkflow.jobs?.["build-and-push-openclaw"]; const managedCaller = baseWorkflow.jobs?.["publish-managed-images"]; const identity = managedWorkflow.jobs?.["publication-identity"]; - const publisher = managedWorkflow.jobs?.["build-and-validate"]; - const promoter = managedWorkflow.jobs?.promote; + const publisher = managedWorkflow.jobs?.["build-and-validate"] as Job | undefined; + const promoter = managedWorkflow.jobs?.promote as Job | undefined; expect(platformProducer?.outputs).toEqual({ "amd64-digest": "${{ steps.platform.outputs.amd64-digest }}", @@ -158,6 +163,11 @@ describe("managed-image failed-job rerun artifacts", () => { }); expect(invalidAttempt.status).not.toBe(0); expect(invalidAttempt.stderr).toContain("producer attempt is invalid"); + const futureAttempt = runCandidateRestore(restoreCandidates.run ?? "", { + OPENCLAW_AMD64_ATTEMPT: "3", + }); + expect(futureAttempt.status).not.toBe(0); + expect(futureAttempt.stderr).toContain("producer attempt is invalid"); const malformedContract = runCandidateRestore(restoreCandidates.run ?? "", { OPENCLAW_AMD64: "not-base64", }); @@ -168,26 +178,7 @@ describe("managed-image failed-job rerun artifacts", () => { const reusedKey = "openclaw|linux/amd64"; const mixedAttempts = runPublicationBarrier( barrier.run ?? "", - (candidateSet) => - candidateSet.map((candidate) => { - const contract = structuredClone(candidate.contract); - const producerAttempt = `${candidate.agent}|${candidate.platform}` === reusedKey ? 1 : 2; - (contract.source as Record).cohort = "ghrun-7744-1"; - (contract.run as Record).attempt = producerAttempt; - const evidence = contract.publicationEvidence as Record; - const attestations = evidence.attestations as Record; - const statement = (attestations.slsa as Record).statement as Record< - string, - unknown - >; - statement.builderId = `https://github.com/NVIDIA/NemoClaw/actions/runs/7744/attempts/${producerAttempt}`; - (statement.bindings as Record).cohort = "ghrun-7744-1"; - return { - ...candidate, - artifact: candidate.artifact.replace("-7744-2-", `-7744-${producerAttempt}-`), - contract, - }; - }), + reuseOpenclawAmd64FromAttemptOne, "", { expectedAttempts: { [reusedKey]: "1" }, @@ -196,6 +187,30 @@ describe("managed-image failed-job rerun artifacts", () => { ); expect(mixedAttempts.status).toBe(0); + const promotion = requiredStep( + promoter?.steps, + "Stage validated multi-platform managed image cohort and contracts", + ); + const mixedPromotion = runManagedImagePromotion(promotion.run ?? "", "", "", { + mutate: reuseOpenclawAmd64FromAttemptOne, + publicationCohort: "ghrun-7744-1", + }); + expect(mixedPromotion.status, mixedPromotion.stderr).toBe(0); + expect(mixedPromotion.platformContracts[reusedKey]?.run).toEqual({ id: 7744, attempt: 1 }); + + const futureCohort = runPublicationBarrier(barrier.run ?? "", (value) => value, "", { + publicationCohort: "ghrun-7744-3", + }); + expect(futureCohort.status).not.toBe(0); + expect(futureCohort.stderr).toContain("publication cohort is invalid"); + expect(futureCohort.dockerCalls).toEqual([]); + const wrongRunCohort = runPublicationBarrier(barrier.run ?? "", (value) => value, "", { + publicationCohort: "ghrun-8877-1", + }); + expect(wrongRunCohort.status).not.toBe(0); + expect(wrongRunCohort.stderr).toContain("publication cohort is invalid"); + expect(wrongRunCohort.dockerCalls).toEqual([]); + const durableUploads = (promoter?.steps ?? []) .filter((candidate) => candidate.uses?.startsWith("actions/upload-artifact@")) .map((candidate) => candidate.with); From 0098d5bded10882cada6bb03fa55f139d3d82ffe Mon Sep 17 00:00:00 2001 From: Julie Yaunches Date: Tue, 18 Aug 2026 22:59:06 -0400 Subject: [PATCH 04/12] fix(ci): reject noncanonical managed image contracts Signed-off-by: Julie Yaunches --- .github/workflows/managed-images.yaml | 16 ++++++- test/managed-image-rerun-artifacts.test.ts | 50 ++++++++++++++++++++-- 2 files changed, 60 insertions(+), 6 deletions(-) diff --git a/.github/workflows/managed-images.yaml b/.github/workflows/managed-images.yaml index 3b25b5c1b85..daacee4c61f 100644 --- a/.github/workflows/managed-images.yaml +++ b/.github/workflows/managed-images.yaml @@ -1601,7 +1601,13 @@ jobs: fi contract_root="$RUNNER_TEMP/managed-base-contract" install -d -m 0700 "$contract_root" - printf '%s' "$contract_base64" | base64 --decode > "$contract_root/contract.json" + if ! printf '%s' "$contract_base64" | + base64 --decode > "$contract_root/contract.json" 2>/dev/null || + [ "$(base64 < "$contract_root/contract.json" | tr -d '\n')" != "$contract_base64" ]; then + rm -f "$contract_root/contract.json" + echo "ERROR: exact base image producer output is not canonical base64." >&2 + exit 1 + fi - name: Validate exact base image contract id: base @@ -2199,7 +2205,13 @@ jobs: local artifact="managed-image-candidate-${GITHUB_RUN_ID}-${producer_attempt}-${agent}-${platform}" local artifact_root="$candidate_root/$artifact" install -d -m 0700 "$artifact_root" - printf '%s' "$contract_base64" | base64 --decode > "$artifact_root/contract.json" + if ! printf '%s' "$contract_base64" | + base64 --decode > "$artifact_root/contract.json" 2>/dev/null || + [ "$(base64 < "$artifact_root/contract.json" | tr -d '\n')" != "$contract_base64" ]; then + rm -f "$artifact_root/contract.json" + echo "ERROR: managed image candidate producer output is not canonical base64." >&2 + exit 1 + fi } restore_candidate openclaw linux-amd64 "$OPENCLAW_AMD64_ATTEMPT" "$OPENCLAW_AMD64" restore_candidate openclaw linux-arm64 "$OPENCLAW_ARM64_ATTEMPT" "$OPENCLAW_ARM64" diff --git a/test/managed-image-rerun-artifacts.test.ts b/test/managed-image-rerun-artifacts.test.ts index 20a8a521aa3..6556539ca35 100644 --- a/test/managed-image-rerun-artifacts.test.ts +++ b/test/managed-image-rerun-artifacts.test.ts @@ -97,6 +97,33 @@ function runCandidateRestore( } } +function runBaseRestore( + script: string, + contract: string, +): { restored: boolean; status: number | null; stderr: string } { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-managed-base-restore-")); + try { + const result = spawnSync("bash", ["-c", script], { + encoding: "utf8", + env: { + ...process.env, + AGENT: "openclaw", + DCODE_CONTRACT_BASE64: contract, + HERMES_CONTRACT_BASE64: contract, + OPENCLAW_CONTRACT_BASE64: contract, + RUNNER_TEMP: root, + }, + }); + return { + restored: fs.existsSync(path.join(root, "managed-base-contract", "contract.json")), + status: result.status, + stderr: result.stderr, + }; + } finally { + fs.rmSync(root, { recursive: true, force: true }); + } +} + describe("managed-image failed-job rerun artifacts", () => { // source-shape-contract: security -- Producer artifact identity must remain exact when GitHub reuses a successful job during a failed-job rerun it("retains exact producer outputs when successful jobs are reused on a failed-job rerun (#9529)", () => { @@ -125,10 +152,20 @@ describe("managed-image failed-job rerun artifacts", () => { expect(managedCaller?.with?.["openclaw-base-contract-base64"]).toBe( "${{ needs.build-and-push-openclaw.outputs.contract-base64 }}", ); - expect( - requiredStep(publisher?.steps, "Restore exact base image contract").env - ?.OPENCLAW_CONTRACT_BASE64, - ).toBe("${{ inputs.openclaw-base-contract-base64 }}"); + const restoreBase = requiredStep(publisher?.steps, "Restore exact base image contract"); + expect(restoreBase.env?.OPENCLAW_CONTRACT_BASE64).toBe( + "${{ inputs.openclaw-base-contract-base64 }}", + ); + const canonicalBase = runBaseRestore( + restoreBase.run ?? "", + Buffer.from("{}\n").toString("base64"), + ); + expect(canonicalBase.status, canonicalBase.stderr).toBe(0); + expect(canonicalBase.restored).toBe(true); + const noncanonicalBase = runBaseRestore(restoreBase.run ?? "", "TR=="); + expect(noncanonicalBase.status).not.toBe(0); + expect(noncanonicalBase.restored).toBe(false); + expect(noncanonicalBase.stderr).not.toContain("TR=="); expect(identity?.outputs).toEqual({ cohort: "${{ steps.identity.outputs.cohort }}", @@ -173,6 +210,11 @@ describe("managed-image failed-job rerun artifacts", () => { }); expect(malformedContract.status).not.toBe(0); expect(malformedContract.stderr).not.toContain("not-base64"); + const noncanonicalContract = runCandidateRestore(restoreCandidates.run ?? "", { + OPENCLAW_AMD64: "TR==", + }); + expect(noncanonicalContract.status).not.toBe(0); + expect(noncanonicalContract.stderr).not.toContain("TR=="); const barrier = requiredStep(promoter?.steps, "Validate complete managed image candidate set"); const reusedKey = "openclaw|linux/amd64"; From 6599ee0d12dac924b0c5b5b6d0b6c45bee36c870 Mon Sep 17 00:00:00 2001 From: Prekshi Vyas Date: Tue, 18 Aug 2026 20:38:10 -0700 Subject: [PATCH 05/12] test(ci): consolidate managed image rerun coverage Signed-off-by: Prekshi Vyas --- ci/source-shape-test-budget.json | 5 - .../managed-image-publication-barrier.ts | 91 ++++++ ...ged-image-publication-workflow-boundary.ts | 146 +++++++++ .../managed-image-publication-workflow.ts | 47 +++ ...managed-image-publication-workflow.test.ts | 309 ++++++++---------- test/managed-image-rerun-artifacts.test.ts | 281 ---------------- 6 files changed, 419 insertions(+), 460 deletions(-) create mode 100644 test/helpers/managed-image-publication-workflow-boundary.ts create mode 100644 test/helpers/managed-image-publication-workflow.ts delete mode 100644 test/managed-image-rerun-artifacts.test.ts diff --git a/ci/source-shape-test-budget.json b/ci/source-shape-test-budget.json index 5187d386bce..5eb801c3dfc 100644 --- a/ci/source-shape-test-budget.json +++ b/ci/source-shape-test-budget.json @@ -76,11 +76,6 @@ "test": "restores exact locked posture after root-separated repair and later failure (#7033)", "category": "security" }, - { - "file": "test/managed-image-rerun-artifacts.test.ts", - "test": "retains exact producer outputs when successful jobs are reused on a failed-job rerun (#9529)", - "category": "security" - }, { "file": "test/muse-glimmer-vllm-image-provenance.test.ts", "test": "rejects %s", diff --git a/test/helpers/managed-image-publication-barrier.ts b/test/helpers/managed-image-publication-barrier.ts index 9872513a84a..c1793f198ce 100644 --- a/test/helpers/managed-image-publication-barrier.ts +++ b/test/helpers/managed-image-publication-barrier.ts @@ -163,6 +163,97 @@ function candidates(): Candidate[] { ); } +export const reuseOpenclawAmd64FromAttemptOne: CandidateMutation = (candidateSet) => + candidateSet.map((candidate) => { + const contract = structuredClone(candidate.contract); + const producerAttempt = + `${candidate.agent}|${candidate.platform}` === "openclaw|linux/amd64" ? 1 : 2; + (contract.source as Record).cohort = "ghrun-7744-1"; + (contract.run as Record).attempt = producerAttempt; + const evidence = contract.publicationEvidence as Record; + const attestations = evidence.attestations as Record; + const statement = (attestations.slsa as Record).statement as Record< + string, + unknown + >; + statement.builderId = `https://github.com/NVIDIA/NemoClaw/actions/runs/7744/attempts/${producerAttempt}`; + (statement.bindings as Record).cohort = "ghrun-7744-1"; + return { + ...candidate, + artifact: candidate.artifact.replace("-7744-2-", `-7744-${producerAttempt}-`), + contract, + }; + }); + +export function runManagedImageCandidateRestore( + script: string, + overrides: Record = {}, +): { files: string[]; status: number | null; stderr: string } { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-managed-restore-")); + const contract = Buffer.from("{}\n").toString("base64"); + try { + const result = spawnSync("bash", ["-c", script], { + encoding: "utf8", + env: { + ...process.env, + DCODE_AMD64: contract, + DCODE_AMD64_ATTEMPT: "1", + DCODE_ARM64: contract, + DCODE_ARM64_ATTEMPT: "2", + GITHUB_RUN_ID: runId, + GITHUB_RUN_ATTEMPT: runAttempt, + HERMES_AMD64: contract, + HERMES_AMD64_ATTEMPT: "1", + HERMES_ARM64: contract, + HERMES_ARM64_ATTEMPT: "2", + OPENCLAW_AMD64: contract, + OPENCLAW_AMD64_ATTEMPT: "1", + OPENCLAW_ARM64: contract, + OPENCLAW_ARM64_ATTEMPT: "2", + RUNNER_TEMP: root, + ...overrides, + }, + }); + const candidateRoot = path.join(root, "managed-image-candidates"); + return { + files: fs.existsSync(candidateRoot) + ? fs.readdirSync(candidateRoot, { recursive: true }).map(String).sort() + : [], + status: result.status, + stderr: result.stderr, + }; + } finally { + fs.rmSync(root, { recursive: true, force: true }); + } +} + +export function runManagedImageBaseRestore( + script: string, + contract: string, +): { restored: boolean; status: number | null; stderr: string } { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-managed-base-restore-")); + try { + const result = spawnSync("bash", ["-c", script], { + encoding: "utf8", + env: { + ...process.env, + AGENT: "openclaw", + DCODE_CONTRACT_BASE64: contract, + HERMES_CONTRACT_BASE64: contract, + OPENCLAW_CONTRACT_BASE64: contract, + RUNNER_TEMP: root, + }, + }); + return { + restored: fs.existsSync(path.join(root, "managed-base-contract", "contract.json")), + status: result.status, + stderr: result.stderr, + }; + } finally { + fs.rmSync(root, { recursive: true, force: true }); + } +} + export function runPublicationBarrier( script: string, mutate: CandidateMutation = (value) => value, diff --git a/test/helpers/managed-image-publication-workflow-boundary.ts b/test/helpers/managed-image-publication-workflow-boundary.ts new file mode 100644 index 00000000000..9f5b02d3e06 --- /dev/null +++ b/test/helpers/managed-image-publication-workflow-boundary.ts @@ -0,0 +1,146 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { + managedPromoter, + managedPublisher, + required, + step, +} from "./managed-image-publication-workflow"; +import type { Workflow } from "./managed-image-publication-workflow-types"; + +const managedInputPaths = [ + ".dockerignore", + ".github/actions/ci-reviewed-npm-audit/**", + ".github/workflows/managed-images.yaml", + "Dockerfile", + "agents/**", + "ci/npm-audit-exceptions.json", + "ci/reviewed-npm-audit.json", + "nemoclaw/**", + "nemoclaw-blueprint/**", + "scripts/**", + "src/lib/actions/sandbox/openshell-child-visible-credentials.v*.json", + "src/lib/core/json-types.ts", + "src/lib/core/ports.ts", + "src/lib/messaging/**", + "src/lib/onboard/managed-bootstrap/envelope.ts", + "src/lib/onboard/managed-startup/**", + "src/lib/security/credential-hash.ts", + "src/lib/state/paths.ts", + "src/lib/state/state-root.ts", + "src/lib/tool-disclosure.ts", + "tools/mcp-tool-discovery-runtime/**", + "tsconfig.runtime-preloads.json", +] as const; + +export 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 before promotion"); + 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", + "/opt/nemoclaw-blueprint/blueprint.yaml", + "/usr/local/share/nemoclaw/corporate-ca.pem", + '--entrypoint "$REQUIRED_BINARY"', + "io.nvidia.nemoclaw.managed-image.contract", + "io.nvidia.nemoclaw.managed-image.startup-profile", + "io.nvidia.nemoclaw.managed-image.capabilities", + "io.nvidia.nemoclaw.managed-image.cohort", + 'cohort_prefix="ghrun-${GITHUB_RUN_ID}-"', + '[ "$cohort_attempt" -gt "$GITHUB_RUN_ATTEMPT" ]', + "NEMOCLAW_MANAGED_IMAGE_CAPABILITY_UNION", + "@openclaw/diagnostics-otel", + "@openclaw/brave-plugin", + "@openclaw/discord", + "@tencent-weixin/openclaw-weixin", + "@openclaw/slack", + "@openclaw/whatsapp", + "@openclaw/msteams", + "@openclaw/googlechat", + "/sandbox/.openclaw/npm/projects", + 'const nodeModulesRoot = path.join(projectRoot, "node_modules")', + 'path.join(nodeModulesRoot, ...name.split("/"))', + "lstatSync(packageRoot).isDirectory()", + "lstatSync(manifestPath).isFile()", + "realpathSync(nodeModulesRoot)", + "realpathSync(packageRoot)", + "packageRelative.startsWith(`..${path.sep}`)", + "path.isAbsolute(packageRelative)", + "matches.length !== 1", + "microsoft-teams-apps", + "config.plugins?.entries?.[id]?.enabled !== false", + 'config["platforms"].get(name) != {"enabled": False}', + "run-managed-image-direct-e2e.ts", + '--agent "$AGENT"', + '--image "$reference"', + '--platform "$PLATFORM"', + ]; + 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") && + base.run.includes(".run.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"]), + ...(JSON.stringify(promoter.needs) === + JSON.stringify(["publication-identity", "build-and-validate"]) + ? [] + : ["aggregate promotion must require every matrix lane"]), + ]; +} diff --git a/test/helpers/managed-image-publication-workflow.ts b/test/helpers/managed-image-publication-workflow.ts new file mode 100644 index 00000000000..c010a091da0 --- /dev/null +++ b/test/helpers/managed-image-publication-workflow.ts @@ -0,0 +1,47 @@ +// 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 YAML from "yaml"; + +import type { Job, Step, Workflow } from "./managed-image-publication-workflow-types"; + +export const repoRoot = path.resolve(import.meta.dirname, "../.."); + +export function readWorkflow(file: string): Workflow { + return YAML.parse( + fs.readFileSync(path.join(repoRoot, ".github", "workflows", file), "utf8"), + ) as Workflow; +} + +export function required(value: T | undefined, message: string): T { + return ( + value ?? + (() => { + throw new Error(message); + })() + ); +} + +export function step(job: Job, name: string): Step { + return required( + job.steps?.find((candidate) => candidate.name === name), + `managed-image workflow is missing '${name}'`, + ); +} + +export function managedPublisher(workflow: Workflow): Job { + return required( + workflow.jobs?.["build-and-validate"], + "managed-image workflow is missing its publisher", + ); +} + +export function managedPromoter(workflow: Workflow): Job { + return required( + workflow.jobs?.promote, + "managed-image workflow is missing its aggregate promoter", + ); +} diff --git a/test/managed-image-publication-workflow.test.ts b/test/managed-image-publication-workflow.test.ts index 90820569323..68d81729bcf 100644 --- a/test/managed-image-publication-workflow.test.ts +++ b/test/managed-image-publication-workflow.test.ts @@ -8,68 +8,35 @@ import os from "node:os"; import path from "node:path"; import { describe, expect, it } from "vitest"; -import YAML from "yaml"; import { publicationAgents, publicationPlatforms, + reuseOpenclawAmd64FromAttemptOne, + runManagedImageBaseRestore, + runManagedImageCandidateRestore, runManagedImagePromotion, runPublicationBarrier, } from "./helpers/managed-image-publication-barrier"; +import { publicationBoundaryErrors } from "./helpers/managed-image-publication-workflow-boundary"; +import { + managedPromoter, + managedPublisher, + readWorkflow, + repoRoot, + required, + step, +} from "./helpers/managed-image-publication-workflow"; import type { Job, MatrixEntry, - Step, Workflow, } from "./helpers/managed-image-publication-workflow-types"; -const repoRoot = path.resolve(import.meta.dirname, ".."); const fullShaAction = /^[^@]+@[0-9a-f]{40}$/iu; -const managedInputPaths = [ - ".dockerignore", - ".github/actions/ci-reviewed-npm-audit/**", - ".github/workflows/managed-images.yaml", - "Dockerfile", - "agents/**", - "ci/npm-audit-exceptions.json", - "ci/reviewed-npm-audit.json", - "nemoclaw/**", - "nemoclaw-blueprint/**", - "scripts/**", - "src/lib/actions/sandbox/openshell-child-visible-credentials.v*.json", - "src/lib/core/json-types.ts", - "src/lib/core/ports.ts", - "src/lib/messaging/**", - "src/lib/onboard/managed-bootstrap/envelope.ts", - "src/lib/onboard/managed-startup/**", - "src/lib/security/credential-hash.ts", - "src/lib/state/paths.ts", - "src/lib/state/state-root.ts", - "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 needsOutput(job: string, output: string): string { + return `\${{ needs.${job}.outputs.${output} }}`; } function inlineNodeStdinValidator(source: string): string { @@ -89,13 +56,6 @@ function isStrictChildPath(root: string, candidate: string): boolean { ); } -function managedPublisher(workflow: Workflow): Job { - return required( - workflow.jobs?.["build-and-validate"], - "managed-image workflow is missing its publisher", - ); -} - const managedBuilder = managedPublisher; function managedPrBuilder(workflow: Workflow): Job { @@ -130,121 +90,6 @@ function managedPrOpenClawMcpDiscovery(workflow: Workflow): Job { return required(workflow.jobs?.["pr-openclaw-mcp-discovery"], "missing exact PR MCP gate"); } -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 before promotion"); - 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", - "/opt/nemoclaw-blueprint/blueprint.yaml", - "/usr/local/share/nemoclaw/corporate-ca.pem", - '--entrypoint "$REQUIRED_BINARY"', - "io.nvidia.nemoclaw.managed-image.contract", - "io.nvidia.nemoclaw.managed-image.startup-profile", - "io.nvidia.nemoclaw.managed-image.capabilities", - "io.nvidia.nemoclaw.managed-image.cohort", - 'cohort_prefix="ghrun-${GITHUB_RUN_ID}-"', - '[ "$cohort_attempt" -gt "$GITHUB_RUN_ATTEMPT" ]', - "NEMOCLAW_MANAGED_IMAGE_CAPABILITY_UNION", - "@openclaw/diagnostics-otel", - "@openclaw/brave-plugin", - "@openclaw/discord", - "@tencent-weixin/openclaw-weixin", - "@openclaw/slack", - "@openclaw/whatsapp", - "@openclaw/msteams", - "@openclaw/googlechat", - "/sandbox/.openclaw/npm/projects", - 'const nodeModulesRoot = path.join(projectRoot, "node_modules")', - 'path.join(nodeModulesRoot, ...name.split("/"))', - "lstatSync(packageRoot).isDirectory()", - "lstatSync(manifestPath).isFile()", - "realpathSync(nodeModulesRoot)", - "realpathSync(packageRoot)", - "packageRelative.startsWith(`..${path.sep}`)", - "path.isAbsolute(packageRelative)", - "matches.length !== 1", - "microsoft-teams-apps", - "config.plugins?.entries?.[id]?.enabled !== false", - 'config["platforms"].get(name) != {"enabled": False}', - "run-managed-image-direct-e2e.ts", - '--agent "$AGENT"', - '--image "$reference"', - '--platform "$PLATFORM"', - ]; - 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") && - base.run.includes(".run.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"]), - ...(JSON.stringify(promoter.needs) === - JSON.stringify(["publication-identity", "build-and-validate"]) - ? [] - : ["aggregate promotion must require every matrix lane"]), - ]; -} - describe("complete managed-image publication workflow", () => { it("rejects managed package paths redirected outside node_modules", () => { const fixtureRoot = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-managed-plugin-")); @@ -267,6 +112,7 @@ describe("complete managed-image publication workflow", () => { it.each([ { agent: "hermes", + contractInput: "hermes-base-contract-base64", displayName: "Hermes", image: "nvidia/nemoclaw/hermes-sandbox-base", job: "build-and-push-hermes", @@ -274,6 +120,7 @@ describe("complete managed-image publication workflow", () => { }, { agent: "langchain-deepagents-code", + contractInput: "dcode-base-contract-base64", displayName: "Deep Agents Code", image: "nvidia/nemoclaw/langchain-deepagents-code-sandbox-base", job: "build-and-push-dcode", @@ -281,6 +128,7 @@ describe("complete managed-image publication workflow", () => { }, { agent: "openclaw", + contractInput: "openclaw-base-contract-base64", displayName: "OpenClaw", image: "nvidia/nemoclaw/sandbox-base", job: "build-and-push-openclaw", @@ -376,6 +224,8 @@ describe("complete managed-image publication workflow", () => { uses: "./.github/actions/publish-base-image-manifest", with: { agent: expectedPublisher.agent, + "amd64-digest": needsOutput(expectedPublisher.platformsJob, "amd64-digest"), + "arm64-digest": needsOutput(expectedPublisher.platformsJob, "arm64-digest"), "display-name": expectedPublisher.displayName, image: expectedPublisher.image, registry: "${{ env.REGISTRY }}", @@ -383,6 +233,12 @@ describe("complete managed-image publication workflow", () => { "registry-password": "${{ secrets.GITHUB_TOKEN }}", }, }); + expect(basePublisher.outputs?.["contract-base64"]).toBe( + "${{ steps.publish.outputs.contract-base64 }}", + ); + expect(publisher.with?.[expectedPublisher.contractInput]).toBe( + needsOutput(expectedPublisher.job, "contract-base64"), + ); expect(step(basePublisher, "Checkout").with?.["persist-credentials"]).toBe(false); const nativePlatforms = required( @@ -390,6 +246,10 @@ describe("complete managed-image publication workflow", () => { `base-image workflow is missing native ${expectedPublisher.agent} platforms`, ); expect(nativePlatforms.needs).toEqual(["reviewed-npm-audit"]); + expect(nativePlatforms.outputs).toEqual({ + "amd64-digest": "${{ steps.platform.outputs.amd64-digest }}", + "arm64-digest": "${{ steps.platform.outputs.arm64-digest }}", + }); expect(nativePlatforms.strategy?.matrix?.include).toEqual( expect.arrayContaining([ expect.objectContaining({ @@ -1077,9 +937,21 @@ fi expect(action.uses, action.name).toMatch(fullShaAction); }); expect(step(publisher, "Checkout").with?.["persist-credentials"]).toBe(false); - expect(step(publisher, "Restore exact base image contract").run).toContain( - 'base64 --decode > "$contract_root/contract.json"', + const restoreBase = step(publisher, "Restore exact base image contract"); + expect(restoreBase.run).toContain('base64 --decode > "$contract_root/contract.json"'); + expect(restoreBase.env?.OPENCLAW_CONTRACT_BASE64).toBe( + "${{ inputs.openclaw-base-contract-base64 }}", ); + const canonicalBase = runManagedImageBaseRestore( + restoreBase.run ?? "", + Buffer.from("{}\n").toString("base64"), + ); + expect(canonicalBase.status, canonicalBase.stderr).toBe(0); + expect(canonicalBase.restored).toBe(true); + const noncanonicalBase = runManagedImageBaseRestore(restoreBase.run ?? "", "TR=="); + expect(noncanonicalBase.status).not.toBe(0); + expect(noncanonicalBase.restored).toBe(false); + expect(noncanonicalBase.stderr).not.toContain("TR=="); const guard = step(publisher, "Validate production build args"); const build = step(publisher, "Build and push managed image by digest"); @@ -1179,8 +1051,11 @@ fi it("holds every alias behind the exact six-candidate aggregate barrier (#7744)", () => { const workflow = readWorkflow("managed-images.yaml"); + const identity = workflow.jobs?.["publication-identity"]; + const publisher = managedPublisher(workflow); const promoter = managedPromoter(workflow); const steps = promoter.steps ?? []; + const restoreCandidates = step(promoter, "Restore all validated managed image candidates"); const barrier = step(promoter, "Validate complete managed image candidate set"); const revalidate = step(promoter, "Revalidate exact managed image publication evidence"); const promotion = step( @@ -1194,10 +1069,48 @@ fi String(candidate.with?.name ?? "").startsWith("managed-image-"), ); + expect(identity?.outputs).toEqual({ cohort: "${{ steps.identity.outputs.cohort }}" }); + expect(publisher.needs).toBe("publication-identity"); + expect(publisher.outputs?.["openclaw-linux-amd64"]).toBe( + "${{ steps.candidate-output.outputs.openclaw_linux_amd64 }}", + ); + expect(publisher.outputs?.["openclaw-linux-amd64-attempt"]).toBe( + "${{ steps.candidate-output.outputs.openclaw_linux_amd64_attempt }}", + ); expect(promoter.needs).toEqual(["publication-identity", "build-and-validate"]); - expect(step(promoter, "Restore all validated managed image candidates").run).toContain( - "restore_candidate()", + expect(restoreCandidates.run).toContain("restore_candidate()"); + expect(restoreCandidates.env).toMatchObject({ + OPENCLAW_AMD64: "${{ needs.build-and-validate.outputs.openclaw-linux-amd64 }}", + OPENCLAW_AMD64_ATTEMPT: + "${{ needs.build-and-validate.outputs.openclaw-linux-amd64-attempt }}", + }); + expect(barrier.env?.PUBLICATION_COHORT).toBe( + "${{ needs.publication-identity.outputs.cohort }}", ); + + const acceptedRestore = runManagedImageCandidateRestore(restoreCandidates.run ?? ""); + expect(acceptedRestore.status, acceptedRestore.stderr).toBe(0); + expect(acceptedRestore.files.filter((file) => file.endsWith("contract.json"))).toHaveLength(6); + const invalidAttempt = runManagedImageCandidateRestore(restoreCandidates.run ?? "", { + OPENCLAW_AMD64_ATTEMPT: "0", + }); + expect(invalidAttempt.status).not.toBe(0); + expect(invalidAttempt.stderr).toContain("producer attempt is invalid"); + const futureAttempt = runManagedImageCandidateRestore(restoreCandidates.run ?? "", { + OPENCLAW_AMD64_ATTEMPT: "3", + }); + expect(futureAttempt.status).not.toBe(0); + expect(futureAttempt.stderr).toContain("producer attempt is invalid"); + const malformedContract = runManagedImageCandidateRestore(restoreCandidates.run ?? "", { + OPENCLAW_AMD64: "not-base64", + }); + expect(malformedContract.status).not.toBe(0); + expect(malformedContract.stderr).not.toContain("not-base64"); + const noncanonicalContract = runManagedImageCandidateRestore(restoreCandidates.run ?? "", { + OPENCLAW_AMD64: "TR==", + }); + expect(noncanonicalContract.status).not.toBe(0); + expect(noncanonicalContract.stderr).not.toContain("TR=="); 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"]'); @@ -1217,7 +1130,24 @@ fi expect(revalidate.run).toContain("registry publication evidence changed"); expect(steps.indexOf(barrier)).toBeLessThan(steps.indexOf(promotion)); expect(steps.indexOf(revalidate)).toBeLessThan(steps.indexOf(promotion)); - expect(durableUploads).toHaveLength(7); + expect(durableUploads.map((upload) => upload.with)).toEqual([ + { + name: "managed-image-cohort-${{ github.run_id }}-${{ github.run_attempt }}", + path: "${{ runner.temp }}/managed-image-contracts/cohort.json", + "if-no-files-found": "error", + "retention-days": 90, + }, + ...publicationAgents.flatMap((agent) => + publicationPlatforms.map((platform) => ({ + name: + "managed-image-${{ github.run_id }}-${{ github.run_attempt }}-" + + `${agent}-${platform.replaceAll("/", "-")}`, + path: `\${{ runner.temp }}/managed-image-contracts/${agent}/${platform.replaceAll("/", "-")}/contract.json`, + "if-no-files-found": "error", + "retention-days": 90, + })), + ), + ]); durableUploads.forEach((upload) => { expect(steps.indexOf(promotion)).toBeLessThan(steps.indexOf(upload)); expect(steps.indexOf(upload)).toBeLessThan(steps.indexOf(pointer)); @@ -1383,6 +1313,29 @@ fi ); expect(runPublicationBarrier(barrier.run ?? "").status).toBe(0); + const reusedKey = "openclaw|linux/amd64"; + const mixedAttempts = runPublicationBarrier( + barrier.run ?? "", + reuseOpenclawAmd64FromAttemptOne, + "", + { + expectedAttempts: { [reusedKey]: "1" }, + publicationCohort: "ghrun-7744-1", + }, + ); + expect(mixedAttempts.status, mixedAttempts.stderr).toBe(0); + const futureCohort = runPublicationBarrier(barrier.run ?? "", (value) => value, "", { + publicationCohort: "ghrun-7744-3", + }); + expect(futureCohort.status).not.toBe(0); + expect(futureCohort.stderr).toContain("publication cohort is invalid"); + expect(futureCohort.dockerCalls).toEqual([]); + const wrongRunCohort = runPublicationBarrier(barrier.run ?? "", (value) => value, "", { + publicationCohort: "ghrun-8877-1", + }); + expect(wrongRunCohort.status).not.toBe(0); + expect(wrongRunCohort.stderr).toContain("publication cohort is invalid"); + expect(wrongRunCohort.dockerCalls).toEqual([]); }); it("stages all multi-platform cohort aliases before moving the sole root pointer (#7744)", () => { @@ -1473,5 +1426,13 @@ fi "langchain-deepagents-code": expect.any(Object), }, }); + + const reusedKey = "openclaw|linux/amd64"; + const mixedPromotion = runManagedImagePromotion(promotion, "", "", { + mutate: reuseOpenclawAmd64FromAttemptOne, + publicationCohort: "ghrun-7744-1", + }); + expect(mixedPromotion.status, mixedPromotion.stderr).toBe(0); + expect(mixedPromotion.platformContracts[reusedKey]?.run).toEqual({ id: 7744, attempt: 1 }); }); }); diff --git a/test/managed-image-rerun-artifacts.test.ts b/test/managed-image-rerun-artifacts.test.ts deleted file mode 100644 index 6556539ca35..00000000000 --- a/test/managed-image-rerun-artifacts.test.ts +++ /dev/null @@ -1,281 +0,0 @@ -// 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"; - -import { describe, expect, it } from "vitest"; -import YAML from "yaml"; - -import { - type CandidateMutation, - publicationAgents, - publicationPlatforms, - runPublicationBarrier, - runManagedImagePromotion, -} from "./helpers/managed-image-publication-barrier"; -import type { Job, Step, Workflow } from "./helpers/managed-image-publication-workflow-types"; - -const repoRoot = path.resolve(import.meta.dirname, ".."); - -function readYaml(file: string): Workflow { - return YAML.parse(fs.readFileSync(path.join(repoRoot, file), "utf8")) as Workflow; -} - -function requiredStep(steps: Step[] | undefined, name: string): Step { - return ( - steps?.find((candidate) => candidate.name === name) ?? - (() => { - throw new Error(`workflow is missing '${name}'`); - })() - ); -} - -const reuseOpenclawAmd64FromAttemptOne: CandidateMutation = (candidateSet) => - candidateSet.map((candidate) => { - const contract = structuredClone(candidate.contract); - const producerAttempt = - `${candidate.agent}|${candidate.platform}` === "openclaw|linux/amd64" ? 1 : 2; - (contract.source as Record).cohort = "ghrun-7744-1"; - (contract.run as Record).attempt = producerAttempt; - const evidence = contract.publicationEvidence as Record; - const attestations = evidence.attestations as Record; - const statement = (attestations.slsa as Record).statement as Record< - string, - unknown - >; - statement.builderId = `https://github.com/NVIDIA/NemoClaw/actions/runs/7744/attempts/${producerAttempt}`; - (statement.bindings as Record).cohort = "ghrun-7744-1"; - return { - ...candidate, - artifact: candidate.artifact.replace("-7744-2-", `-7744-${producerAttempt}-`), - contract, - }; - }); - -function runCandidateRestore( - script: string, - overrides: Record = {}, -): { files: string[]; status: number | null; stderr: string } { - const root = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-managed-restore-")); - const contract = Buffer.from("{}\n").toString("base64"); - try { - const result = spawnSync("bash", ["-c", script], { - encoding: "utf8", - env: { - ...process.env, - DCODE_AMD64: contract, - DCODE_AMD64_ATTEMPT: "1", - DCODE_ARM64: contract, - DCODE_ARM64_ATTEMPT: "2", - GITHUB_RUN_ID: "7744", - GITHUB_RUN_ATTEMPT: "2", - HERMES_AMD64: contract, - HERMES_AMD64_ATTEMPT: "1", - HERMES_ARM64: contract, - HERMES_ARM64_ATTEMPT: "2", - OPENCLAW_AMD64: contract, - OPENCLAW_AMD64_ATTEMPT: "1", - OPENCLAW_ARM64: contract, - OPENCLAW_ARM64_ATTEMPT: "2", - RUNNER_TEMP: root, - ...overrides, - }, - }); - const candidateRoot = path.join(root, "managed-image-candidates"); - return { - files: fs.existsSync(candidateRoot) - ? fs.readdirSync(candidateRoot, { recursive: true }).map(String).sort() - : [], - status: result.status, - stderr: result.stderr, - }; - } finally { - fs.rmSync(root, { recursive: true, force: true }); - } -} - -function runBaseRestore( - script: string, - contract: string, -): { restored: boolean; status: number | null; stderr: string } { - const root = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-managed-base-restore-")); - try { - const result = spawnSync("bash", ["-c", script], { - encoding: "utf8", - env: { - ...process.env, - AGENT: "openclaw", - DCODE_CONTRACT_BASE64: contract, - HERMES_CONTRACT_BASE64: contract, - OPENCLAW_CONTRACT_BASE64: contract, - RUNNER_TEMP: root, - }, - }); - return { - restored: fs.existsSync(path.join(root, "managed-base-contract", "contract.json")), - status: result.status, - stderr: result.stderr, - }; - } finally { - fs.rmSync(root, { recursive: true, force: true }); - } -} - -describe("managed-image failed-job rerun artifacts", () => { - // source-shape-contract: security -- Producer artifact identity must remain exact when GitHub reuses a successful job during a failed-job rerun - it("retains exact producer outputs when successful jobs are reused on a failed-job rerun (#9529)", () => { - const baseWorkflow = readYaml(".github/workflows/base-image.yaml"); - const managedWorkflow = readYaml(".github/workflows/managed-images.yaml"); - const platformProducer = baseWorkflow.jobs?.["build-openclaw-platforms"]; - const baseProducer = baseWorkflow.jobs?.["build-and-push-openclaw"]; - const managedCaller = baseWorkflow.jobs?.["publish-managed-images"]; - const identity = managedWorkflow.jobs?.["publication-identity"]; - const publisher = managedWorkflow.jobs?.["build-and-validate"] as Job | undefined; - const promoter = managedWorkflow.jobs?.promote as Job | undefined; - - expect(platformProducer?.outputs).toEqual({ - "amd64-digest": "${{ steps.platform.outputs.amd64-digest }}", - "arm64-digest": "${{ steps.platform.outputs.arm64-digest }}", - }); - expect( - requiredStep(baseProducer?.steps, "Publish validated multi-platform manifest").with, - ).toMatchObject({ - "amd64-digest": "${{ needs.build-openclaw-platforms.outputs.amd64-digest }}", - "arm64-digest": "${{ needs.build-openclaw-platforms.outputs.arm64-digest }}", - }); - expect(baseProducer?.outputs?.["contract-base64"]).toBe( - "${{ steps.publish.outputs.contract-base64 }}", - ); - expect(managedCaller?.with?.["openclaw-base-contract-base64"]).toBe( - "${{ needs.build-and-push-openclaw.outputs.contract-base64 }}", - ); - const restoreBase = requiredStep(publisher?.steps, "Restore exact base image contract"); - expect(restoreBase.env?.OPENCLAW_CONTRACT_BASE64).toBe( - "${{ inputs.openclaw-base-contract-base64 }}", - ); - const canonicalBase = runBaseRestore( - restoreBase.run ?? "", - Buffer.from("{}\n").toString("base64"), - ); - expect(canonicalBase.status, canonicalBase.stderr).toBe(0); - expect(canonicalBase.restored).toBe(true); - const noncanonicalBase = runBaseRestore(restoreBase.run ?? "", "TR=="); - expect(noncanonicalBase.status).not.toBe(0); - expect(noncanonicalBase.restored).toBe(false); - expect(noncanonicalBase.stderr).not.toContain("TR=="); - - expect(identity?.outputs).toEqual({ - cohort: "${{ steps.identity.outputs.cohort }}", - }); - expect(publisher?.needs).toBe("publication-identity"); - expect(publisher?.outputs?.["openclaw-linux-amd64"]).toBe( - "${{ steps.candidate-output.outputs.openclaw_linux_amd64 }}", - ); - expect(publisher?.outputs?.["openclaw-linux-amd64-attempt"]).toBe( - "${{ steps.candidate-output.outputs.openclaw_linux_amd64_attempt }}", - ); - const restoreCandidates = requiredStep( - promoter?.steps, - "Restore all validated managed image candidates", - ); - expect(restoreCandidates.env).toMatchObject({ - OPENCLAW_AMD64: "${{ needs.build-and-validate.outputs.openclaw-linux-amd64 }}", - OPENCLAW_AMD64_ATTEMPT: - "${{ needs.build-and-validate.outputs.openclaw-linux-amd64-attempt }}", - }); - expect(promoter?.needs).toEqual(["publication-identity", "build-and-validate"]); - expect( - requiredStep(promoter?.steps, "Validate complete managed image candidate set").env - ?.PUBLICATION_COHORT, - ).toBe("${{ needs.publication-identity.outputs.cohort }}"); - - const acceptedRestore = runCandidateRestore(restoreCandidates.run ?? ""); - expect(acceptedRestore.status).toBe(0); - expect(acceptedRestore.files.filter((file) => file.endsWith("contract.json"))).toHaveLength(6); - const invalidAttempt = runCandidateRestore(restoreCandidates.run ?? "", { - OPENCLAW_AMD64_ATTEMPT: "0", - }); - expect(invalidAttempt.status).not.toBe(0); - expect(invalidAttempt.stderr).toContain("producer attempt is invalid"); - const futureAttempt = runCandidateRestore(restoreCandidates.run ?? "", { - OPENCLAW_AMD64_ATTEMPT: "3", - }); - expect(futureAttempt.status).not.toBe(0); - expect(futureAttempt.stderr).toContain("producer attempt is invalid"); - const malformedContract = runCandidateRestore(restoreCandidates.run ?? "", { - OPENCLAW_AMD64: "not-base64", - }); - expect(malformedContract.status).not.toBe(0); - expect(malformedContract.stderr).not.toContain("not-base64"); - const noncanonicalContract = runCandidateRestore(restoreCandidates.run ?? "", { - OPENCLAW_AMD64: "TR==", - }); - expect(noncanonicalContract.status).not.toBe(0); - expect(noncanonicalContract.stderr).not.toContain("TR=="); - - const barrier = requiredStep(promoter?.steps, "Validate complete managed image candidate set"); - const reusedKey = "openclaw|linux/amd64"; - const mixedAttempts = runPublicationBarrier( - barrier.run ?? "", - reuseOpenclawAmd64FromAttemptOne, - "", - { - expectedAttempts: { [reusedKey]: "1" }, - publicationCohort: "ghrun-7744-1", - }, - ); - expect(mixedAttempts.status).toBe(0); - - const promotion = requiredStep( - promoter?.steps, - "Stage validated multi-platform managed image cohort and contracts", - ); - const mixedPromotion = runManagedImagePromotion(promotion.run ?? "", "", "", { - mutate: reuseOpenclawAmd64FromAttemptOne, - publicationCohort: "ghrun-7744-1", - }); - expect(mixedPromotion.status, mixedPromotion.stderr).toBe(0); - expect(mixedPromotion.platformContracts[reusedKey]?.run).toEqual({ id: 7744, attempt: 1 }); - - const futureCohort = runPublicationBarrier(barrier.run ?? "", (value) => value, "", { - publicationCohort: "ghrun-7744-3", - }); - expect(futureCohort.status).not.toBe(0); - expect(futureCohort.stderr).toContain("publication cohort is invalid"); - expect(futureCohort.dockerCalls).toEqual([]); - const wrongRunCohort = runPublicationBarrier(barrier.run ?? "", (value) => value, "", { - publicationCohort: "ghrun-8877-1", - }); - expect(wrongRunCohort.status).not.toBe(0); - expect(wrongRunCohort.stderr).toContain("publication cohort is invalid"); - expect(wrongRunCohort.dockerCalls).toEqual([]); - - const durableUploads = (promoter?.steps ?? []) - .filter((candidate) => candidate.uses?.startsWith("actions/upload-artifact@")) - .map((candidate) => candidate.with); - expect(durableUploads).toEqual([ - { - name: "managed-image-cohort-${{ github.run_id }}-${{ github.run_attempt }}", - 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.replaceAll("/", "-"); - return { - name: - "managed-image-${{ github.run_id }}-${{ github.run_attempt }}-" + - `${agent}-${artifactPlatform}`, - path: `\${{ runner.temp }}/managed-image-contracts/${agent}/${artifactPlatform}/contract.json`, - "if-no-files-found": "error", - "retention-days": 90, - }; - }), - ), - ]); - }); -}); From 74499713c06ece363e89ceede5ea89a66ccd1226 Mon Sep 17 00:00:00 2001 From: Prekshi Vyas Date: Tue, 18 Aug 2026 20:52:13 -0700 Subject: [PATCH 06/12] test(ci): lock unique matrix output aggregation Signed-off-by: Prekshi Vyas --- ...anaged-image-publication-workflow-types.ts | 5 +++ .../managed-image-publication-workflow.ts | 8 +++- ...managed-image-publication-workflow.test.ts | 43 ++++++++++++++++--- 3 files changed, 50 insertions(+), 6 deletions(-) diff --git a/test/helpers/managed-image-publication-workflow-types.ts b/test/helpers/managed-image-publication-workflow-types.ts index 7f805cebeb0..8ae3b607bac 100644 --- a/test/helpers/managed-image-publication-workflow-types.ts +++ b/test/helpers/managed-image-publication-workflow-types.ts @@ -12,6 +12,11 @@ export type Step = { "working-directory"?: string; }; +export type Action = { + outputs?: Record; + runs?: { steps?: Step[] }; +}; + export type MatrixEntry = { agent?: string; arch?: string; diff --git a/test/helpers/managed-image-publication-workflow.ts b/test/helpers/managed-image-publication-workflow.ts index c010a091da0..ac2b5e984db 100644 --- a/test/helpers/managed-image-publication-workflow.ts +++ b/test/helpers/managed-image-publication-workflow.ts @@ -6,7 +6,7 @@ import path from "node:path"; import YAML from "yaml"; -import type { Job, Step, Workflow } from "./managed-image-publication-workflow-types"; +import type { Action, Job, Step, Workflow } from "./managed-image-publication-workflow-types"; export const repoRoot = path.resolve(import.meta.dirname, "../.."); @@ -16,6 +16,12 @@ export function readWorkflow(file: string): Workflow { ) as Workflow; } +export function readAction(directory: string): Action { + return YAML.parse( + fs.readFileSync(path.join(repoRoot, ".github", "actions", directory, "action.yaml"), "utf8"), + ) as Action; +} + export function required(value: T | undefined, message: string): T { return ( value ?? diff --git a/test/managed-image-publication-workflow.test.ts b/test/managed-image-publication-workflow.test.ts index 68d81729bcf..1a50588a609 100644 --- a/test/managed-image-publication-workflow.test.ts +++ b/test/managed-image-publication-workflow.test.ts @@ -22,6 +22,7 @@ import { publicationBoundaryErrors } from "./helpers/managed-image-publication-w import { managedPromoter, managedPublisher, + readAction, readWorkflow, repoRoot, required, @@ -250,6 +251,11 @@ describe("complete managed-image publication workflow", () => { "amd64-digest": "${{ steps.platform.outputs.amd64-digest }}", "arm64-digest": "${{ steps.platform.outputs.arm64-digest }}", }); + const platformLanes = nativePlatforms.strategy?.matrix?.include ?? []; + expect(platformLanes.map(({ arch }) => `${arch}-digest`).sort()).toEqual( + Object.keys(nativePlatforms.outputs ?? {}).sort(), + ); + expect(new Set(Object.values(nativePlatforms.outputs ?? {})).size).toBe(platformLanes.length); expect(nativePlatforms.strategy?.matrix?.include).toEqual( expect.arrayContaining([ expect.objectContaining({ @@ -267,6 +273,18 @@ describe("complete managed-image publication workflow", () => { }, ); + it("exports one unique base digest output for every native matrix lane (#9529)", () => { + const action = readAction("build-base-image-platform"); + expect(action.outputs).toMatchObject({ + "amd64-digest": { value: "${{ steps.job-output.outputs.amd64_digest }}" }, + "arm64-digest": { value: "${{ steps.job-output.outputs.arm64_digest }}" }, + }); + expect(new Set(Object.values(action.outputs ?? {}).map(({ value }) => value)).size).toBe(2); + + const exportDigest = step({ steps: action.runs?.steps }, "Export platform digest"); + expect(exportDigest.run).toContain('printf \'%s_digest=%s\\n\' "$ARCH" "$DIGEST"'); + }); + it("builds and exercises every shipped agent from an exact PR image before merge (#7744)", () => { const workflow = readWorkflow("managed-images.yaml"); const reviewedAudit = managedPrReviewedAudit(workflow); @@ -1071,12 +1089,27 @@ fi expect(identity?.outputs).toEqual({ cohort: "${{ steps.identity.outputs.cohort }}" }); expect(publisher.needs).toBe("publication-identity"); - expect(publisher.outputs?.["openclaw-linux-amd64"]).toBe( - "${{ steps.candidate-output.outputs.openclaw_linux_amd64 }}", - ); - expect(publisher.outputs?.["openclaw-linux-amd64-attempt"]).toBe( - "${{ steps.candidate-output.outputs.openclaw_linux_amd64_attempt }}", + const candidateOutputs = Object.fromEntries( + (publisher.strategy?.matrix?.include ?? []).flatMap(({ agent, artifact_platform }) => { + const requiredAgent = required(agent, "managed image lane is missing its agent"); + const requiredPlatform = required( + artifact_platform, + "managed image lane is missing its artifact platform", + ); + const lane = `${requiredAgent === "langchain-deepagents-code" ? "dcode" : requiredAgent}-${requiredPlatform}`; + const stepOutput = `${requiredAgent}_${requiredPlatform}`.replaceAll("-", "_"); + return [ + [lane, `\${{ steps.candidate-output.outputs.${stepOutput} }}`], + [`${lane}-attempt`, `\${{ steps.candidate-output.outputs.${stepOutput}_attempt }}`], + ]; + }), ); + expect(publisher.outputs).toEqual(candidateOutputs); + expect(new Set(Object.values(publisher.outputs ?? {})).size).toBe(12); + const candidateOutput = step(publisher, "Export validated managed image candidate output"); + expect(candidateOutput.run).toContain('output_name="${AGENT//-/_}_${ARTIFACT_PLATFORM//-/_}"'); + expect(candidateOutput.run).toContain("printf '%s=%s\\n' \"$output_name\""); + expect(candidateOutput.run).toContain("printf '%s_attempt=%s\\n' \"$output_name\""); expect(promoter.needs).toEqual(["publication-identity", "build-and-validate"]); expect(restoreCandidates.run).toContain("restore_candidate()"); expect(restoreCandidates.env).toMatchObject({ From 2d3ebd0f7ae129b7b56091a9a273be75a73d2033 Mon Sep 17 00:00:00 2001 From: Julie Yaunches Date: Wed, 19 Aug 2026 00:07:19 -0400 Subject: [PATCH 07/12] test(ci): clarify managed image workflow failures Signed-off-by: Julie Yaunches --- .../managed-image-publication-workflow.ts | 4 +-- ...managed-image-publication-workflow.test.ts | 25 ++++++++++++++++--- 2 files changed, 24 insertions(+), 5 deletions(-) diff --git a/test/helpers/managed-image-publication-workflow.ts b/test/helpers/managed-image-publication-workflow.ts index ac2b5e984db..80bfcafd37a 100644 --- a/test/helpers/managed-image-publication-workflow.ts +++ b/test/helpers/managed-image-publication-workflow.ts @@ -31,10 +31,10 @@ export function required(value: T | undefined, message: string): T { ); } -export function step(job: Job, name: string): Step { +export function step(job: Job, name: string, container = "managed-image workflow"): Step { return required( job.steps?.find((candidate) => candidate.name === name), - `managed-image workflow is missing '${name}'`, + `${container} is missing '${name}'`, ); } diff --git a/test/managed-image-publication-workflow.test.ts b/test/managed-image-publication-workflow.test.ts index 1a50588a609..8751414249e 100644 --- a/test/managed-image-publication-workflow.test.ts +++ b/test/managed-image-publication-workflow.test.ts @@ -220,7 +220,11 @@ describe("complete managed-image publication workflow", () => { `base-image workflow is missing ${expectedPublisher.agent} manifest publisher`, ); expect(basePublisher.needs).toEqual([expectedPublisher.platformsJob, "reviewed-npm-audit"]); - const manifest = step(basePublisher, "Publish validated multi-platform manifest"); + const manifest = step( + basePublisher, + "Publish validated multi-platform manifest", + "base-image workflow", + ); expect(manifest).toMatchObject({ uses: "./.github/actions/publish-base-image-manifest", with: { @@ -240,7 +244,9 @@ describe("complete managed-image publication workflow", () => { expect(publisher.with?.[expectedPublisher.contractInput]).toBe( needsOutput(expectedPublisher.job, "contract-base64"), ); - expect(step(basePublisher, "Checkout").with?.["persist-credentials"]).toBe(false); + expect( + step(basePublisher, "Checkout", "base-image workflow").with?.["persist-credentials"], + ).toBe(false); const nativePlatforms = required( baseWorkflow.jobs?.[expectedPublisher.platformsJob], @@ -281,7 +287,11 @@ describe("complete managed-image publication workflow", () => { }); expect(new Set(Object.values(action.outputs ?? {}).map(({ value }) => value)).size).toBe(2); - const exportDigest = step({ steps: action.runs?.steps }, "Export platform digest"); + const exportDigest = step( + { steps: action.runs?.steps }, + "Export platform digest", + "build-base-image-platform action", + ); expect(exportDigest.run).toContain('printf \'%s_digest=%s\\n\' "$ARCH" "$DIGEST"'); }); @@ -969,6 +979,9 @@ fi const noncanonicalBase = runManagedImageBaseRestore(restoreBase.run ?? "", "TR=="); expect(noncanonicalBase.status).not.toBe(0); expect(noncanonicalBase.restored).toBe(false); + expect(noncanonicalBase.stderr).toContain( + "exact base image producer output is not canonical base64", + ); expect(noncanonicalBase.stderr).not.toContain("TR=="); const guard = step(publisher, "Validate production build args"); @@ -1138,11 +1151,17 @@ fi OPENCLAW_AMD64: "not-base64", }); expect(malformedContract.status).not.toBe(0); + expect(malformedContract.stderr).toContain( + "managed image candidate producer output is not canonical base64", + ); expect(malformedContract.stderr).not.toContain("not-base64"); const noncanonicalContract = runManagedImageCandidateRestore(restoreCandidates.run ?? "", { OPENCLAW_AMD64: "TR==", }); expect(noncanonicalContract.status).not.toBe(0); + expect(noncanonicalContract.stderr).toContain( + "managed image candidate producer output is not canonical base64", + ); expect(noncanonicalContract.stderr).not.toContain("TR=="); expect(barrier.run).toContain("expected exactly six managed image candidate artifacts"); expect(barrier.run).toContain("length == 6"); From cfe51a67731e0db7d7c424f95260ce6c8cb8c8f3 Mon Sep 17 00:00:00 2001 From: Prekshi Vyas Date: Tue, 18 Aug 2026 21:46:02 -0700 Subject: [PATCH 08/12] fix(ci): isolate base digest job outputs Signed-off-by: Prekshi Vyas --- .github/workflows/base-image.yaml | 294 ++++++++++-------- ...managed-image-publication-workflow.test.ts | 104 ++++--- 2 files changed, 228 insertions(+), 170 deletions(-) diff --git a/.github/workflows/base-image.yaml b/.github/workflows/base-image.yaml index 5d2a1b97dc3..496b5e8562b 100644 --- a/.github/workflows/base-image.yaml +++ b/.github/workflows/base-image.yaml @@ -117,35 +117,18 @@ jobs: target-root: ${{ github.workspace }} report-dir: artifacts/reviewed-npm-audit - # The complete Perl suite approaches the image-job timeout under QEMU arm64 emulation. - # Build OpenClaw on native runners and 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 }}) + # A failed-job rerun can reuse either successful platform job from an earlier + # attempt. Keep each digest on a distinct job output so completion order cannot + # replace the sibling architecture with an empty matrix output. + build-openclaw-amd64: + name: Build OpenClaw base image (amd64) if: github.repository == 'NVIDIA/NemoClaw' needs: - reviewed-npm-audit - runs-on: ${{ matrix.runner }} + runs-on: ubuntu-24.04 timeout-minutes: 60 outputs: - amd64-digest: ${{ steps.platform.outputs.amd64-digest }} - arm64-digest: ${{ steps.platform.outputs.arm64-digest }} - 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 + digest: ${{ steps.platform.outputs.amd64-digest }} steps: - name: Checkout uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 @@ -156,11 +139,11 @@ jobs: id: platform uses: ./.github/actions/build-base-image-platform with: - agent: ${{ matrix.agent }} - arch: ${{ matrix.arch }} - platform: ${{ matrix.platform }} - dockerfile: ${{ matrix.dockerfile }} - image: ${{ matrix.image }} + agent: openclaw + arch: amd64 + platform: linux/amd64 + dockerfile: Dockerfile.base + image: nvidia/nemoclaw/sandbox-base registry: ${{ env.REGISTRY }} registry-username: ${{ github.actor }} registry-password: ${{ secrets.GITHUB_TOKEN }} @@ -170,38 +153,51 @@ jobs: type=ref,event=tag type=sha,prefix=,format=short + build-openclaw-arm64: + name: Build OpenClaw base image (arm64) + if: github.repository == 'NVIDIA/NemoClaw' + needs: + - reviewed-npm-audit + runs-on: ubuntu-24.04-arm + timeout-minutes: 60 + outputs: + digest: ${{ steps.platform.outputs.arm64-digest }} + steps: + - name: Checkout + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + + - name: Build and publish platform digest + id: platform + uses: ./.github/actions/build-base-image-platform + with: + agent: openclaw + arch: arm64 + platform: linux/arm64 + dockerfile: Dockerfile.base + image: nvidia/nemoclaw/sandbox-base + registry: ${{ env.REGISTRY }} + registry-username: ${{ github.actor }} + registry-password: ${{ secrets.GITHUB_TOKEN }} + openclaw-version: ${{ inputs.openclaw_version }} + metadata-tags: | + type=raw,value=latest,enable=${{ github.ref == 'refs/heads/main' }} + type=ref,event=tag + type=sha,prefix=,format=short # The complete Perl suite approaches the image-job timeout under QEMU arm64 emulation. # Build each sibling image on native architecture runners and publish only immutable platform digests. # Each manifest job updates its image tags after both platform builds pass. - build-hermes-platforms: - name: Build ${{ matrix.display_name }} base image (${{ matrix.arch }}) + build-hermes-amd64: + name: Build Hermes base image (amd64) if: github.repository == 'NVIDIA/NemoClaw' needs: - reviewed-npm-audit - runs-on: ${{ matrix.runner }} + runs-on: ubuntu-24.04 timeout-minutes: 60 outputs: - amd64-digest: ${{ steps.platform.outputs.amd64-digest }} - arm64-digest: ${{ steps.platform.outputs.arm64-digest }} - strategy: - fail-fast: false - matrix: - include: - - agent: hermes - display_name: Hermes - arch: amd64 - platform: linux/amd64 - runner: ubuntu-24.04 - dockerfile: agents/hermes/Dockerfile.base - image: nvidia/nemoclaw/hermes-sandbox-base - - agent: hermes - display_name: Hermes - arch: arm64 - platform: linux/arm64 - runner: ubuntu-24.04-arm - dockerfile: agents/hermes/Dockerfile.base - image: nvidia/nemoclaw/hermes-sandbox-base + digest: ${{ steps.platform.outputs.amd64-digest }} steps: - name: Checkout uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 @@ -212,44 +208,52 @@ jobs: id: platform uses: ./.github/actions/build-base-image-platform with: - agent: ${{ matrix.agent }} - arch: ${{ matrix.arch }} - platform: ${{ matrix.platform }} - dockerfile: ${{ matrix.dockerfile }} - image: ${{ matrix.image }} + agent: hermes + arch: amd64 + platform: linux/amd64 + dockerfile: agents/hermes/Dockerfile.base + image: nvidia/nemoclaw/hermes-sandbox-base registry: ${{ env.REGISTRY }} registry-username: ${{ github.actor }} registry-password: ${{ secrets.GITHUB_TOKEN }} + build-hermes-arm64: + name: Build Hermes base image (arm64) + if: github.repository == 'NVIDIA/NemoClaw' + needs: + - reviewed-npm-audit + runs-on: ubuntu-24.04-arm + timeout-minutes: 60 + outputs: + digest: ${{ steps.platform.outputs.arm64-digest }} + steps: + - name: Checkout + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false - build-dcode-platforms: - name: Build ${{ matrix.display_name }} base image (${{ matrix.arch }}) + - name: Build and publish platform digest + id: platform + uses: ./.github/actions/build-base-image-platform + with: + agent: hermes + arch: arm64 + platform: linux/arm64 + dockerfile: agents/hermes/Dockerfile.base + image: nvidia/nemoclaw/hermes-sandbox-base + registry: ${{ env.REGISTRY }} + registry-username: ${{ github.actor }} + registry-password: ${{ secrets.GITHUB_TOKEN }} + + build-dcode-amd64: + name: Build Deep Agents Code base image (amd64) if: github.repository == 'NVIDIA/NemoClaw' needs: - reviewed-npm-audit - runs-on: ${{ matrix.runner }} + runs-on: ubuntu-24.04 timeout-minutes: 60 outputs: - amd64-digest: ${{ steps.platform.outputs.amd64-digest }} - arm64-digest: ${{ steps.platform.outputs.arm64-digest }} - strategy: - fail-fast: false - matrix: - include: - - agent: langchain-deepagents-code - display_name: Deep Agents Code - arch: amd64 - platform: linux/amd64 - runner: ubuntu-24.04 - dockerfile: agents/langchain-deepagents-code/Dockerfile.base - image: nvidia/nemoclaw/langchain-deepagents-code-sandbox-base - - agent: langchain-deepagents-code - display_name: Deep Agents Code - arch: arm64 - platform: linux/arm64 - runner: ubuntu-24.04-arm - dockerfile: agents/langchain-deepagents-code/Dockerfile.base - image: nvidia/nemoclaw/langchain-deepagents-code-sandbox-base + digest: ${{ steps.platform.outputs.amd64-digest }} steps: - name: Checkout uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 @@ -260,67 +264,108 @@ jobs: id: platform uses: ./.github/actions/build-base-image-platform with: - agent: ${{ matrix.agent }} - arch: ${{ matrix.arch }} - platform: ${{ matrix.platform }} - dockerfile: ${{ matrix.dockerfile }} - image: ${{ matrix.image }} + agent: langchain-deepagents-code + arch: amd64 + platform: linux/amd64 + dockerfile: agents/langchain-deepagents-code/Dockerfile.base + image: nvidia/nemoclaw/langchain-deepagents-code-sandbox-base registry: ${{ env.REGISTRY }} registry-username: ${{ github.actor }} registry-password: ${{ secrets.GITHUB_TOKEN }} + build-dcode-arm64: + name: Build Deep Agents Code base image (arm64) + if: github.repository == 'NVIDIA/NemoClaw' + needs: + - reviewed-npm-audit + runs-on: ubuntu-24.04-arm + timeout-minutes: 60 + outputs: + digest: ${{ steps.platform.outputs.arm64-digest }} + steps: + - name: Checkout + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + + - name: Build and publish platform digest + id: platform + uses: ./.github/actions/build-base-image-platform + with: + agent: langchain-deepagents-code + arch: arm64 + platform: linux/arm64 + dockerfile: agents/langchain-deepagents-code/Dockerfile.base + image: nvidia/nemoclaw/langchain-deepagents-code-sandbox-base + registry: ${{ env.REGISTRY }} + registry-username: ${{ github.actor }} + registry-password: ${{ secrets.GITHUB_TOKEN }} # Pi is a candidate agent runtime: this workflow publishes its base image so # CI can validate exact digests, while Pi stays out of the shipped # managed-image agent cohort. - build-pi-platforms: - name: Build Pi base image (${{ matrix.arch }}) + build-pi-amd64: + name: Build Pi base image (amd64) + if: github.repository == 'NVIDIA/NemoClaw' + needs: + - reviewed-npm-audit + runs-on: ubuntu-24.04 + timeout-minutes: 60 + outputs: + digest: ${{ steps.platform.outputs.amd64-digest }} + steps: + - name: Checkout + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + + - name: Build and publish platform digest + id: platform + uses: ./.github/actions/build-base-image-platform + with: + agent: pi + arch: amd64 + platform: linux/amd64 + dockerfile: agents/pi/Dockerfile.base + image: nvidia/nemoclaw/pi-sandbox-base + registry: ${{ env.REGISTRY }} + registry-username: ${{ github.actor }} + registry-password: ${{ secrets.GITHUB_TOKEN }} + + build-pi-arm64: + name: Build Pi base image (arm64) if: github.repository == 'NVIDIA/NemoClaw' needs: - reviewed-npm-audit - runs-on: ${{ matrix.runner }} + runs-on: ubuntu-24.04-arm timeout-minutes: 60 outputs: - amd64-digest: ${{ steps.platform.outputs.amd64-digest }} - arm64-digest: ${{ steps.platform.outputs.arm64-digest }} - strategy: - fail-fast: false - matrix: - include: - - agent: pi - arch: amd64 - platform: linux/amd64 - runner: ubuntu-24.04 - dockerfile: agents/pi/Dockerfile.base - image: nvidia/nemoclaw/pi-sandbox-base - - agent: pi - arch: arm64 - platform: linux/arm64 - runner: ubuntu-24.04-arm - dockerfile: agents/pi/Dockerfile.base - image: nvidia/nemoclaw/pi-sandbox-base + digest: ${{ steps.platform.outputs.arm64-digest }} steps: - name: Checkout uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false + - name: Build and publish platform digest id: platform uses: ./.github/actions/build-base-image-platform with: - agent: ${{ matrix.agent }} - arch: ${{ matrix.arch }} - platform: ${{ matrix.platform }} - dockerfile: ${{ matrix.dockerfile }} - image: ${{ matrix.image }} + agent: pi + arch: arm64 + platform: linux/arm64 + dockerfile: agents/pi/Dockerfile.base + image: nvidia/nemoclaw/pi-sandbox-base registry: ${{ env.REGISTRY }} registry-username: ${{ github.actor }} registry-password: ${{ secrets.GITHUB_TOKEN }} + build-and-push-pi: name: Build and push Pi base image if: github.repository == 'NVIDIA/NemoClaw' needs: - - build-pi-platforms + - build-pi-amd64 + - build-pi-arm64 - reviewed-npm-audit runs-on: ubuntu-latest timeout-minutes: 10 @@ -336,8 +381,8 @@ jobs: uses: ./.github/actions/publish-base-image-manifest with: agent: pi - amd64-digest: ${{ needs.build-pi-platforms.outputs.amd64-digest }} - arm64-digest: ${{ needs.build-pi-platforms.outputs.arm64-digest }} + amd64-digest: ${{ needs.build-pi-amd64.outputs.digest }} + arm64-digest: ${{ needs.build-pi-arm64.outputs.digest }} display-name: Pi image: nvidia/nemoclaw/pi-sandbox-base registry: ${{ env.REGISTRY }} @@ -347,7 +392,8 @@ jobs: name: Build and push Hermes base image if: github.repository == 'NVIDIA/NemoClaw' needs: - - build-hermes-platforms + - build-hermes-amd64 + - build-hermes-arm64 - reviewed-npm-audit runs-on: ubuntu-latest timeout-minutes: 10 @@ -364,8 +410,8 @@ jobs: uses: ./.github/actions/publish-base-image-manifest with: agent: hermes - amd64-digest: ${{ needs.build-hermes-platforms.outputs.amd64-digest }} - arm64-digest: ${{ needs.build-hermes-platforms.outputs.arm64-digest }} + amd64-digest: ${{ needs.build-hermes-amd64.outputs.digest }} + arm64-digest: ${{ needs.build-hermes-arm64.outputs.digest }} display-name: Hermes image: nvidia/nemoclaw/hermes-sandbox-base registry: ${{ env.REGISTRY }} @@ -377,7 +423,8 @@ jobs: name: Build and push Deep Agents Code base image if: github.repository == 'NVIDIA/NemoClaw' needs: - - build-dcode-platforms + - build-dcode-amd64 + - build-dcode-arm64 - reviewed-npm-audit runs-on: ubuntu-latest timeout-minutes: 10 @@ -394,8 +441,8 @@ jobs: uses: ./.github/actions/publish-base-image-manifest with: agent: langchain-deepagents-code - amd64-digest: ${{ needs.build-dcode-platforms.outputs.amd64-digest }} - arm64-digest: ${{ needs.build-dcode-platforms.outputs.arm64-digest }} + amd64-digest: ${{ needs.build-dcode-amd64.outputs.digest }} + arm64-digest: ${{ needs.build-dcode-arm64.outputs.digest }} display-name: Deep Agents Code image: nvidia/nemoclaw/langchain-deepagents-code-sandbox-base registry: ${{ env.REGISTRY }} @@ -409,7 +456,8 @@ jobs: name: Build and push OpenClaw base image if: github.repository == 'NVIDIA/NemoClaw' needs: - - build-openclaw-platforms + - build-openclaw-amd64 + - build-openclaw-arm64 - reviewed-npm-audit runs-on: ubuntu-latest timeout-minutes: 10 @@ -426,8 +474,8 @@ jobs: uses: ./.github/actions/publish-base-image-manifest with: agent: openclaw - amd64-digest: ${{ needs.build-openclaw-platforms.outputs.amd64-digest }} - arm64-digest: ${{ needs.build-openclaw-platforms.outputs.arm64-digest }} + amd64-digest: ${{ needs.build-openclaw-amd64.outputs.digest }} + arm64-digest: ${{ needs.build-openclaw-arm64.outputs.digest }} display-name: OpenClaw image: nvidia/nemoclaw/sandbox-base registry: ${{ env.REGISTRY }} diff --git a/test/managed-image-publication-workflow.test.ts b/test/managed-image-publication-workflow.test.ts index 8751414249e..ab30fd015b0 100644 --- a/test/managed-image-publication-workflow.test.ts +++ b/test/managed-image-publication-workflow.test.ts @@ -90,7 +90,6 @@ function managedPrActivation(workflow: Workflow): Job { function managedPrOpenClawMcpDiscovery(workflow: Workflow): Job { return required(workflow.jobs?.["pr-openclaw-mcp-discovery"], "missing exact PR MCP gate"); } - describe("complete managed-image publication workflow", () => { it("rejects managed package paths redirected outside node_modules", () => { const fixtureRoot = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-managed-plugin-")); @@ -102,7 +101,6 @@ describe("complete managed-image publication workflow", () => { fs.mkdirSync(installedPackageRoot, { recursive: true }); fs.mkdirSync(path.join(outsideScope, "plugin"), { recursive: true }); fs.symlinkSync(outsideScope, path.join(nodeModulesRoot, "@scope")); - expect(isStrictChildPath(nodeModulesRoot, installedPackageRoot)).toBe(true); expect(isStrictChildPath(nodeModulesRoot, escapedPackageRoot)).toBe(false); } finally { @@ -117,7 +115,8 @@ describe("complete managed-image publication workflow", () => { displayName: "Hermes", image: "nvidia/nemoclaw/hermes-sandbox-base", job: "build-and-push-hermes", - platformsJob: "build-hermes-platforms", + amd64Job: "build-hermes-amd64", + arm64Job: "build-hermes-arm64", }, { agent: "langchain-deepagents-code", @@ -125,7 +124,8 @@ describe("complete managed-image publication workflow", () => { displayName: "Deep Agents Code", image: "nvidia/nemoclaw/langchain-deepagents-code-sandbox-base", job: "build-and-push-dcode", - platformsJob: "build-dcode-platforms", + amd64Job: "build-dcode-amd64", + arm64Job: "build-dcode-arm64", }, { agent: "openclaw", @@ -133,7 +133,8 @@ describe("complete managed-image publication workflow", () => { displayName: "OpenClaw", image: "nvidia/nemoclaw/sandbox-base", job: "build-and-push-openclaw", - platformsJob: "build-openclaw-platforms", + amd64Job: "build-openclaw-amd64", + arm64Job: "build-openclaw-arm64", }, ] as const)( "starts the $agent publisher after exact base contracts without canceling release tags (#7744)", @@ -144,7 +145,6 @@ describe("complete managed-image publication workflow", () => { baseWorkflow.jobs?.["publish-managed-images"], "base-image workflow is missing the managed-image publisher", ); - expect(publicationBoundaryErrors(baseWorkflow, managedWorkflow)).toEqual([]); expect(JSON.stringify(managedWorkflow)).not.toContain("config.plugins?.installs?.[id]"); const validationRun = @@ -195,7 +195,6 @@ describe("complete managed-image publication workflow", () => { 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 reviewedAudit = required( baseWorkflow.jobs?.["reviewed-npm-audit"], "base-image workflow is missing the reviewed npm audit", @@ -214,12 +213,15 @@ describe("complete managed-image publication workflow", () => { "target-root": "${{ github.workspace }}", }, }); - const basePublisher = required( baseWorkflow.jobs?.[expectedPublisher.job], `base-image workflow is missing ${expectedPublisher.agent} manifest publisher`, ); - expect(basePublisher.needs).toEqual([expectedPublisher.platformsJob, "reviewed-npm-audit"]); + expect(basePublisher.needs).toEqual([ + expectedPublisher.amd64Job, + expectedPublisher.arm64Job, + "reviewed-npm-audit", + ]); const manifest = step( basePublisher, "Publish validated multi-platform manifest", @@ -229,8 +231,8 @@ describe("complete managed-image publication workflow", () => { uses: "./.github/actions/publish-base-image-manifest", with: { agent: expectedPublisher.agent, - "amd64-digest": needsOutput(expectedPublisher.platformsJob, "amd64-digest"), - "arm64-digest": needsOutput(expectedPublisher.platformsJob, "arm64-digest"), + "amd64-digest": needsOutput(expectedPublisher.amd64Job, "digest"), + "arm64-digest": needsOutput(expectedPublisher.arm64Job, "digest"), "display-name": expectedPublisher.displayName, image: expectedPublisher.image, registry: "${{ env.REGISTRY }}", @@ -247,54 +249,63 @@ describe("complete managed-image publication workflow", () => { expect( step(basePublisher, "Checkout", "base-image workflow").with?.["persist-credentials"], ).toBe(false); - - const nativePlatforms = required( - baseWorkflow.jobs?.[expectedPublisher.platformsJob], - `base-image workflow is missing native ${expectedPublisher.agent} platforms`, + const amd64Job = required( + baseWorkflow.jobs?.[expectedPublisher.amd64Job], + `base-image workflow is missing native ${expectedPublisher.agent} amd64`, ); - expect(nativePlatforms.needs).toEqual(["reviewed-npm-audit"]); - expect(nativePlatforms.outputs).toEqual({ - "amd64-digest": "${{ steps.platform.outputs.amd64-digest }}", - "arm64-digest": "${{ steps.platform.outputs.arm64-digest }}", + expect(amd64Job).toMatchObject({ + needs: ["reviewed-npm-audit"], + outputs: { digest: "${{ steps.platform.outputs.amd64-digest }}" }, + "runs-on": "ubuntu-24.04", }); - const platformLanes = nativePlatforms.strategy?.matrix?.include ?? []; - expect(platformLanes.map(({ arch }) => `${arch}-digest`).sort()).toEqual( - Object.keys(nativePlatforms.outputs ?? {}).sort(), - ); - expect(new Set(Object.values(nativePlatforms.outputs ?? {})).size).toBe(platformLanes.length); - 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", - }), - ]), + expect(amd64Job.strategy).toBeUndefined(); + expect( + step(amd64Job, "Build and publish platform digest", "base-image workflow").with, + ).toMatchObject({ + agent: expectedPublisher.agent, + arch: "amd64", + platform: "linux/amd64", + }); + const arm64Job = required( + baseWorkflow.jobs?.[expectedPublisher.arm64Job], + `base-image workflow is missing native ${expectedPublisher.agent} arm64`, ); + expect(arm64Job).toMatchObject({ + needs: ["reviewed-npm-audit"], + outputs: { digest: "${{ steps.platform.outputs.arm64-digest }}" }, + "runs-on": "ubuntu-24.04-arm", + }); + expect(arm64Job.strategy).toBeUndefined(); + expect( + step(arm64Job, "Build and publish platform digest", "base-image workflow").with, + ).toMatchObject({ + agent: expectedPublisher.agent, + arch: "arm64", + platform: "linux/arm64", + }); }, ); - - it("exports one unique base digest output for every native matrix lane (#9529)", () => { + it("exports one architecture-specific digest from every native platform action (#9529)", () => { const action = readAction("build-base-image-platform"); expect(action.outputs).toMatchObject({ "amd64-digest": { value: "${{ steps.job-output.outputs.amd64_digest }}" }, "arm64-digest": { value: "${{ steps.job-output.outputs.arm64_digest }}" }, }); expect(new Set(Object.values(action.outputs ?? {}).map(({ value }) => value)).size).toBe(2); - - const exportDigest = step( - { steps: action.runs?.steps }, - "Export platform digest", - "build-base-image-platform action", - ); + const exportDigest = step({ steps: action.runs?.steps }, "Export platform digest", "build-base-image-platform action"); expect(exportDigest.run).toContain('printf \'%s_digest=%s\\n\' "$ARCH" "$DIGEST"'); }); - + it("binds each Pi digest to its non-matrix producer (#9529)", () => { + const publisher = required( + readWorkflow("base-image.yaml").jobs?.["build-and-push-pi"], + "base-image workflow is missing the Pi manifest publisher", + ); + expect(publisher.needs).toEqual(["build-pi-amd64", "build-pi-arm64", "reviewed-npm-audit"]); + expect(step(publisher, "Publish validated multi-platform manifest").with).toMatchObject({ + "amd64-digest": needsOutput("build-pi-amd64", "digest"), + "arm64-digest": needsOutput("build-pi-arm64", "digest"), + }); + }); it("builds and exercises every shipped agent from an exact PR image before merge (#7744)", () => { const workflow = readWorkflow("managed-images.yaml"); const reviewedAudit = managedPrReviewedAudit(workflow); @@ -305,7 +316,6 @@ describe("complete managed-image publication workflow", () => { const localBaseBuild = step(prBuilder, "Build PR managed image from local base"); const registryBaseBuild = step(prBuilder, "Build PR managed image from registry base"); const contract = step(prBuilder, "Validate exact PR managed image contract"); - expect(workflow.on?.pull_request?.paths).toEqual( expect.arrayContaining([ ".github/actions/ci-reviewed-npm-audit/**", From bdb1cd73170d3992414908f7d059ce804a5c8341 Mon Sep 17 00:00:00 2001 From: Prekshi Vyas Date: Tue, 18 Aug 2026 22:01:57 -0700 Subject: [PATCH 09/12] test(ci): align base image runner contract Signed-off-by: Prekshi Vyas --- test/perl-critical-cve-remediation.test.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/test/perl-critical-cve-remediation.test.ts b/test/perl-critical-cve-remediation.test.ts index 22f40962343..88b8c89e66a 100644 --- a/test/perl-critical-cve-remediation.test.ts +++ b/test/perl-critical-cve-remediation.test.ts @@ -417,8 +417,8 @@ env "\${perl_test_env[@]}" bash -c 'test "$NEMOCLAW_PERL_SKIP_RAW_ICMPV4_TESTS" it.each(Array.from(managedImages, (value) => [value]))( "builds both architectures and $name from the PR head (#7338)", (image) => { - expect(baseImageWorkflow).toContain("runner: ubuntu-24.04"); - expect(baseImageWorkflow).toContain("runner: ubuntu-24.04-arm"); + expect(baseImageWorkflow).toContain("runs-on: ubuntu-24.04"); + expect(baseImageWorkflow).toContain("runs-on: ubuntu-24.04-arm"); expect(baseImageWorkflow).toContain("platform: linux/amd64"); expect(baseImageWorkflow).toContain("platform: linux/arm64"); From 3cceea5f2ef1c32ec38dc2e1a31cd0b9e45cacf0 Mon Sep 17 00:00:00 2001 From: Prekshi Vyas Date: Tue, 18 Aug 2026 22:18:55 -0700 Subject: [PATCH 10/12] fix(ci): preserve matrix candidates across reruns Signed-off-by: Prekshi Vyas --- .github/workflows/managed-images.yaml | 151 ++++-------------- .../managed-image-publication-barrier.ts | 54 +------ ...managed-image-publication-workflow.test.ts | 103 +++++------- 3 files changed, 76 insertions(+), 232 deletions(-) diff --git a/.github/workflows/managed-images.yaml b/.github/workflows/managed-images.yaml index daacee4c61f..b61c7e2639d 100644 --- a/.github/workflows/managed-images.yaml +++ b/.github/workflows/managed-images.yaml @@ -1489,19 +1489,6 @@ jobs: needs: publication-identity runs-on: ${{ matrix.runner }} timeout-minutes: 120 - outputs: - openclaw-linux-amd64: ${{ steps.candidate-output.outputs.openclaw_linux_amd64 }} - openclaw-linux-amd64-attempt: ${{ steps.candidate-output.outputs.openclaw_linux_amd64_attempt }} - openclaw-linux-arm64: ${{ steps.candidate-output.outputs.openclaw_linux_arm64 }} - openclaw-linux-arm64-attempt: ${{ steps.candidate-output.outputs.openclaw_linux_arm64_attempt }} - hermes-linux-amd64: ${{ steps.candidate-output.outputs.hermes_linux_amd64 }} - hermes-linux-amd64-attempt: ${{ steps.candidate-output.outputs.hermes_linux_amd64_attempt }} - hermes-linux-arm64: ${{ steps.candidate-output.outputs.hermes_linux_arm64 }} - hermes-linux-arm64-attempt: ${{ steps.candidate-output.outputs.hermes_linux_arm64_attempt }} - dcode-linux-amd64: ${{ steps.candidate-output.outputs.langchain_deepagents_code_linux_amd64 }} - dcode-linux-amd64-attempt: ${{ steps.candidate-output.outputs.langchain_deepagents_code_linux_amd64_attempt }} - dcode-linux-arm64: ${{ steps.candidate-output.outputs.langchain_deepagents_code_linux_arm64 }} - dcode-linux-arm64-attempt: ${{ steps.candidate-output.outputs.langchain_deepagents_code_linux_arm64_attempt }} strategy: fail-fast: false matrix: @@ -2125,33 +2112,14 @@ jobs: - name: Upload validated managed image candidate uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: - name: managed-image-candidate-${{ github.run_id }}-${{ github.run_attempt }}-${{ matrix.agent }}-${{ matrix.artifact_platform }} + # A failed-job rerun keeps successful lanes and replaces only the + # stable artifact owned by each retried agent/platform lane. + name: managed-image-candidate-${{ github.run_id }}-${{ matrix.agent }}-${{ matrix.artifact_platform }} path: ${{ runner.temp }}/managed-image-candidate/contract.json if-no-files-found: error + overwrite: true retention-days: 1 - - name: Export validated managed image candidate output - id: candidate-output - shell: bash - env: - AGENT: ${{ matrix.agent }} - ARTIFACT_PLATFORM: ${{ matrix.artifact_platform }} - run: | - set -euo pipefail - contract="$RUNNER_TEMP/managed-image-candidate/contract.json" - if [ ! -f "$contract" ] || [ -L "$contract" ]; then - echo "ERROR: managed image candidate contract is missing or unsafe." >&2 - exit 1 - fi - contract_size="$(wc -c < "$contract" | tr -d '[:space:]')" - if [[ ! "$contract_size" =~ ^[1-9][0-9]{0,6}$ ]] || [ "$contract_size" -gt 262144 ]; then - echo "ERROR: managed image candidate contract is empty or oversized." >&2 - exit 1 - fi - output_name="${AGENT//-/_}_${ARTIFACT_PLATFORM//-/_}" - printf '%s=%s\n' "$output_name" "$(base64 -w 0 "$contract")" >> "$GITHUB_OUTPUT" - printf '%s_attempt=%s\n' "$output_name" "$GITHUB_RUN_ATTEMPT" >> "$GITHUB_OUTPUT" - promote: name: Promote complete multi-platform managed image cohort needs: @@ -2164,61 +2132,10 @@ jobs: packages: write steps: - name: Restore all validated managed image candidates - shell: bash - env: - DCODE_AMD64: ${{ needs.build-and-validate.outputs.dcode-linux-amd64 }} - DCODE_AMD64_ATTEMPT: ${{ needs.build-and-validate.outputs.dcode-linux-amd64-attempt }} - DCODE_ARM64: ${{ needs.build-and-validate.outputs.dcode-linux-arm64 }} - DCODE_ARM64_ATTEMPT: ${{ needs.build-and-validate.outputs.dcode-linux-arm64-attempt }} - HERMES_AMD64: ${{ needs.build-and-validate.outputs.hermes-linux-amd64 }} - HERMES_AMD64_ATTEMPT: ${{ needs.build-and-validate.outputs.hermes-linux-amd64-attempt }} - HERMES_ARM64: ${{ needs.build-and-validate.outputs.hermes-linux-arm64 }} - HERMES_ARM64_ATTEMPT: ${{ needs.build-and-validate.outputs.hermes-linux-arm64-attempt }} - OPENCLAW_AMD64: ${{ needs.build-and-validate.outputs.openclaw-linux-amd64 }} - OPENCLAW_AMD64_ATTEMPT: ${{ needs.build-and-validate.outputs.openclaw-linux-amd64-attempt }} - OPENCLAW_ARM64: ${{ needs.build-and-validate.outputs.openclaw-linux-arm64 }} - OPENCLAW_ARM64_ATTEMPT: ${{ needs.build-and-validate.outputs.openclaw-linux-arm64-attempt }} - run: | - set -euo pipefail - candidate_root="$RUNNER_TEMP/managed-image-candidates" - install -d -m 0700 "$candidate_root" - restore_candidate() { - local agent="$1" - local platform="$2" - local producer_attempt="$3" - local contract_base64="$4" - if [[ ! "$producer_attempt" =~ ^[1-9][0-9]{0,9}$ ]] || - [[ ! "$GITHUB_RUN_ATTEMPT" =~ ^[1-9][0-9]{0,9}$ ]] || - [ "$producer_attempt" -gt "$GITHUB_RUN_ATTEMPT" ]; then - echo "ERROR: managed image candidate producer attempt is invalid." >&2 - exit 1 - fi - if [ -z "$contract_base64" ] || [ "${#contract_base64}" -gt 524288 ]; then - echo "ERROR: managed image candidate producer output is missing or oversized." >&2 - exit 1 - fi - if [[ ! "$contract_base64" =~ ^[A-Za-z0-9+/]+={0,2}$ ]] || - [ $(( ${#contract_base64} % 4 )) -ne 0 ]; then - echo "ERROR: managed image candidate producer output is not canonical base64." >&2 - exit 1 - fi - local artifact="managed-image-candidate-${GITHUB_RUN_ID}-${producer_attempt}-${agent}-${platform}" - local artifact_root="$candidate_root/$artifact" - install -d -m 0700 "$artifact_root" - if ! printf '%s' "$contract_base64" | - base64 --decode > "$artifact_root/contract.json" 2>/dev/null || - [ "$(base64 < "$artifact_root/contract.json" | tr -d '\n')" != "$contract_base64" ]; then - rm -f "$artifact_root/contract.json" - echo "ERROR: managed image candidate producer output is not canonical base64." >&2 - exit 1 - fi - } - restore_candidate openclaw linux-amd64 "$OPENCLAW_AMD64_ATTEMPT" "$OPENCLAW_AMD64" - restore_candidate openclaw linux-arm64 "$OPENCLAW_ARM64_ATTEMPT" "$OPENCLAW_ARM64" - restore_candidate hermes linux-amd64 "$HERMES_AMD64_ATTEMPT" "$HERMES_AMD64" - restore_candidate hermes linux-arm64 "$HERMES_ARM64_ATTEMPT" "$HERMES_ARM64" - restore_candidate langchain-deepagents-code linux-amd64 "$DCODE_AMD64_ATTEMPT" "$DCODE_AMD64" - restore_candidate langchain-deepagents-code linux-arm64 "$DCODE_ARM64_ATTEMPT" "$DCODE_ARM64" + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + pattern: managed-image-candidate-${{ github.run_id }}-* + path: ${{ runner.temp }}/managed-image-candidates # This is the all-agent/all-architecture publication barrier. It fails # closed before registry authentication or any alias operation. @@ -2227,12 +2144,6 @@ jobs: shell: bash env: CANDIDATE_ROOT: ${{ runner.temp }}/managed-image-candidates - DCODE_AMD64_ATTEMPT: ${{ needs.build-and-validate.outputs.dcode-linux-amd64-attempt }} - DCODE_ARM64_ATTEMPT: ${{ needs.build-and-validate.outputs.dcode-linux-arm64-attempt }} - HERMES_AMD64_ATTEMPT: ${{ needs.build-and-validate.outputs.hermes-linux-amd64-attempt }} - HERMES_ARM64_ATTEMPT: ${{ needs.build-and-validate.outputs.hermes-linux-arm64-attempt }} - OPENCLAW_AMD64_ATTEMPT: ${{ needs.build-and-validate.outputs.openclaw-linux-amd64-attempt }} - OPENCLAW_ARM64_ATTEMPT: ${{ needs.build-and-validate.outputs.openclaw-linux-arm64-attempt }} PUBLICATION_COHORT: ${{ needs.publication-identity.outputs.cohort }} run: | set -euo pipefail @@ -2264,20 +2175,12 @@ jobs: fi expected_artifacts=( - "managed-image-candidate-${GITHUB_RUN_ID}-${OPENCLAW_AMD64_ATTEMPT}-openclaw-linux-amd64" - "managed-image-candidate-${GITHUB_RUN_ID}-${OPENCLAW_ARM64_ATTEMPT}-openclaw-linux-arm64" - "managed-image-candidate-${GITHUB_RUN_ID}-${HERMES_AMD64_ATTEMPT}-hermes-linux-amd64" - "managed-image-candidate-${GITHUB_RUN_ID}-${HERMES_ARM64_ATTEMPT}-hermes-linux-arm64" - "managed-image-candidate-${GITHUB_RUN_ID}-${DCODE_AMD64_ATTEMPT}-langchain-deepagents-code-linux-amd64" - "managed-image-candidate-${GITHUB_RUN_ID}-${DCODE_ARM64_ATTEMPT}-langchain-deepagents-code-linux-arm64" - ) - expected_attempts=( - "$OPENCLAW_AMD64_ATTEMPT" - "$OPENCLAW_ARM64_ATTEMPT" - "$HERMES_AMD64_ATTEMPT" - "$HERMES_ARM64_ATTEMPT" - "$DCODE_AMD64_ATTEMPT" - "$DCODE_ARM64_ATTEMPT" + "managed-image-candidate-${GITHUB_RUN_ID}-openclaw-linux-amd64" + "managed-image-candidate-${GITHUB_RUN_ID}-openclaw-linux-arm64" + "managed-image-candidate-${GITHUB_RUN_ID}-hermes-linux-amd64" + "managed-image-candidate-${GITHUB_RUN_ID}-hermes-linux-arm64" + "managed-image-candidate-${GITHUB_RUN_ID}-langchain-deepagents-code-linux-amd64" + "managed-image-candidate-${GITHUB_RUN_ID}-langchain-deepagents-code-linux-arm64" ) expected_agents=( openclaw @@ -2309,11 +2212,11 @@ jobs: fi candidate_files=() + expected_attempts=() for index in "${!expected_artifacts[@]}"; do artifact="${expected_artifacts[$index]}" expected_agent="${expected_agents[$index]}" expected_platform="${expected_platforms[$index]}" - expected_attempt="${expected_attempts[$index]}" artifact_dir="$CANDIDATE_ROOT/$artifact" contract="$artifact_dir/contract.json" if [ ! -d "$artifact_dir" ] || [ -L "$artifact_dir" ] || @@ -2325,6 +2228,17 @@ jobs: find "$artifact_dir" ! -path "$artifact_dir" -prune -print | wc -l | tr -d '[:space:]' )" + expected_attempt="$( + jq -er '.run.attempt | select(type == "number" and . >= 1 and floor == .)' \ + "$contract" + )" || { + echo "ERROR: managed image candidate producer attempt is invalid: $artifact" >&2 + exit 1 + } + if [ "$expected_attempt" -gt "$GITHUB_RUN_ATTEMPT" ]; then + echo "ERROR: managed image candidate producer attempt is invalid: $artifact" >&2 + exit 1 + fi if [ "$artifact_entry_count" != "1" ] || ! jq -e \ --arg agent "$expected_agent" \ @@ -2338,18 +2252,19 @@ jobs: exit 1 fi candidate_files+=("$contract") + expected_attempts+=("$expected_attempt") done candidate_set="$RUNNER_TEMP/managed-image-candidate-set.json" jq -s 'sort_by(.agent, .platform)' "${candidate_files[@]}" > "$candidate_set" expected_attempts_json="$( jq -cn \ - --argjson openclawAmd64 "$OPENCLAW_AMD64_ATTEMPT" \ - --argjson openclawArm64 "$OPENCLAW_ARM64_ATTEMPT" \ - --argjson hermesAmd64 "$HERMES_AMD64_ATTEMPT" \ - --argjson hermesArm64 "$HERMES_ARM64_ATTEMPT" \ - --argjson dcodeAmd64 "$DCODE_AMD64_ATTEMPT" \ - --argjson dcodeArm64 "$DCODE_ARM64_ATTEMPT" \ + --argjson openclawAmd64 "${expected_attempts[0]}" \ + --argjson openclawArm64 "${expected_attempts[1]}" \ + --argjson hermesAmd64 "${expected_attempts[2]}" \ + --argjson hermesArm64 "${expected_attempts[3]}" \ + --argjson dcodeAmd64 "${expected_attempts[4]}" \ + --argjson dcodeArm64 "${expected_attempts[5]}" \ '{ "openclaw|linux/amd64": $openclawAmd64, "openclaw|linux/arm64": $openclawArm64, diff --git a/test/helpers/managed-image-publication-barrier.ts b/test/helpers/managed-image-publication-barrier.ts index c1793f198ce..7d2a8d9af2f 100644 --- a/test/helpers/managed-image-publication-barrier.ts +++ b/test/helpers/managed-image-publication-barrier.ts @@ -25,7 +25,6 @@ type Candidate = { export type CandidateMutation = (candidates: Candidate[]) => Candidate[]; type BarrierOptions = { - expectedAttempts?: Partial>; publicationCohort?: string; }; @@ -64,7 +63,7 @@ function candidates(): Candidate[] { return { agent, platform, - artifact: `managed-image-candidate-${runId}-${runAttempt}-${agent}-${platform.replaceAll("/", "-")}`, + artifact: `managed-image-candidate-${runId}-${agent}-${platform.replaceAll("/", "-")}`, contract: { contractVersion: 2, phase: "candidate", @@ -180,53 +179,10 @@ export const reuseOpenclawAmd64FromAttemptOne: CandidateMutation = (candidateSet (statement.bindings as Record).cohort = "ghrun-7744-1"; return { ...candidate, - artifact: candidate.artifact.replace("-7744-2-", `-7744-${producerAttempt}-`), contract, }; }); -export function runManagedImageCandidateRestore( - script: string, - overrides: Record = {}, -): { files: string[]; status: number | null; stderr: string } { - const root = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-managed-restore-")); - const contract = Buffer.from("{}\n").toString("base64"); - try { - const result = spawnSync("bash", ["-c", script], { - encoding: "utf8", - env: { - ...process.env, - DCODE_AMD64: contract, - DCODE_AMD64_ATTEMPT: "1", - DCODE_ARM64: contract, - DCODE_ARM64_ATTEMPT: "2", - GITHUB_RUN_ID: runId, - GITHUB_RUN_ATTEMPT: runAttempt, - HERMES_AMD64: contract, - HERMES_AMD64_ATTEMPT: "1", - HERMES_ARM64: contract, - HERMES_ARM64_ATTEMPT: "2", - OPENCLAW_AMD64: contract, - OPENCLAW_AMD64_ATTEMPT: "1", - OPENCLAW_ARM64: contract, - OPENCLAW_ARM64_ATTEMPT: "2", - RUNNER_TEMP: root, - ...overrides, - }, - }); - const candidateRoot = path.join(root, "managed-image-candidates"); - return { - files: fs.existsSync(candidateRoot) - ? fs.readdirSync(candidateRoot, { recursive: true }).map(String).sort() - : [], - status: result.status, - stderr: result.stderr, - }; - } finally { - fs.rmSync(root, { recursive: true, force: true }); - } -} - export function runManagedImageBaseRestore( script: string, contract: string, @@ -300,14 +256,6 @@ export function runPublicationBarrier( GITHUB_RUN_ATTEMPT: runAttempt, GITHUB_RUN_ID: runId, GITHUB_SHA: revision, - DCODE_AMD64_ATTEMPT: - options.expectedAttempts?.["langchain-deepagents-code|linux/amd64"] ?? runAttempt, - DCODE_ARM64_ATTEMPT: - options.expectedAttempts?.["langchain-deepagents-code|linux/arm64"] ?? runAttempt, - HERMES_AMD64_ATTEMPT: options.expectedAttempts?.["hermes|linux/amd64"] ?? runAttempt, - HERMES_ARM64_ATTEMPT: options.expectedAttempts?.["hermes|linux/arm64"] ?? runAttempt, - OPENCLAW_AMD64_ATTEMPT: options.expectedAttempts?.["openclaw|linux/amd64"] ?? runAttempt, - OPENCLAW_ARM64_ATTEMPT: options.expectedAttempts?.["openclaw|linux/arm64"] ?? runAttempt, PATH: `${bin}:${process.env.PATH ?? ""}`, PUBLICATION_COHORT: options.publicationCohort ?? cohort, RUNNER_TEMP: root, diff --git a/test/managed-image-publication-workflow.test.ts b/test/managed-image-publication-workflow.test.ts index ab30fd015b0..4466a09d957 100644 --- a/test/managed-image-publication-workflow.test.ts +++ b/test/managed-image-publication-workflow.test.ts @@ -14,7 +14,6 @@ import { publicationPlatforms, reuseOpenclawAmd64FromAttemptOne, runManagedImageBaseRestore, - runManagedImageCandidateRestore, runManagedImagePromotion, runPublicationBarrier, } from "./helpers/managed-image-publication-barrier"; @@ -1041,9 +1040,10 @@ fi ]; expect(contractMarkers.filter((marker) => contract.run?.includes(marker) !== true)).toEqual([]); expect(step(publisher, "Upload validated managed image candidate").with).toMatchObject({ - name: "managed-image-candidate-${{ github.run_id }}-${{ github.run_attempt }}-${{ matrix.agent }}-${{ matrix.artifact_platform }}", + name: "managed-image-candidate-${{ github.run_id }}-${{ matrix.agent }}-${{ matrix.artifact_platform }}", path: "${{ runner.temp }}/managed-image-candidate/contract.json", "if-no-files-found": "error", + overwrite: true, "retention-days": 1, }); const validation = required(validate.run, "managed image validation script is missing"); @@ -1112,68 +1112,24 @@ fi expect(identity?.outputs).toEqual({ cohort: "${{ steps.identity.outputs.cohort }}" }); expect(publisher.needs).toBe("publication-identity"); - const candidateOutputs = Object.fromEntries( - (publisher.strategy?.matrix?.include ?? []).flatMap(({ agent, artifact_platform }) => { - const requiredAgent = required(agent, "managed image lane is missing its agent"); - const requiredPlatform = required( - artifact_platform, - "managed image lane is missing its artifact platform", - ); - const lane = `${requiredAgent === "langchain-deepagents-code" ? "dcode" : requiredAgent}-${requiredPlatform}`; - const stepOutput = `${requiredAgent}_${requiredPlatform}`.replaceAll("-", "_"); - return [ - [lane, `\${{ steps.candidate-output.outputs.${stepOutput} }}`], - [`${lane}-attempt`, `\${{ steps.candidate-output.outputs.${stepOutput}_attempt }}`], - ]; - }), - ); - expect(publisher.outputs).toEqual(candidateOutputs); - expect(new Set(Object.values(publisher.outputs ?? {})).size).toBe(12); - const candidateOutput = step(publisher, "Export validated managed image candidate output"); - expect(candidateOutput.run).toContain('output_name="${AGENT//-/_}_${ARTIFACT_PLATFORM//-/_}"'); - expect(candidateOutput.run).toContain("printf '%s=%s\\n' \"$output_name\""); - expect(candidateOutput.run).toContain("printf '%s_attempt=%s\\n' \"$output_name\""); + expect(publisher.outputs).toBeUndefined(); + expect(publisher.steps?.map((candidate) => candidate.name)).not.toContain( + "Export validated managed image candidate output", + ); expect(promoter.needs).toEqual(["publication-identity", "build-and-validate"]); - expect(restoreCandidates.run).toContain("restore_candidate()"); - expect(restoreCandidates.env).toMatchObject({ - OPENCLAW_AMD64: "${{ needs.build-and-validate.outputs.openclaw-linux-amd64 }}", - OPENCLAW_AMD64_ATTEMPT: - "${{ needs.build-and-validate.outputs.openclaw-linux-amd64-attempt }}", + expect(restoreCandidates).toMatchObject({ + uses: "actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c", + with: { + pattern: "managed-image-candidate-${{ github.run_id }}-*", + path: "${{ runner.temp }}/managed-image-candidates", + }, }); expect(barrier.env?.PUBLICATION_COHORT).toBe( "${{ needs.publication-identity.outputs.cohort }}", ); - - const acceptedRestore = runManagedImageCandidateRestore(restoreCandidates.run ?? ""); - expect(acceptedRestore.status, acceptedRestore.stderr).toBe(0); - expect(acceptedRestore.files.filter((file) => file.endsWith("contract.json"))).toHaveLength(6); - const invalidAttempt = runManagedImageCandidateRestore(restoreCandidates.run ?? "", { - OPENCLAW_AMD64_ATTEMPT: "0", - }); - expect(invalidAttempt.status).not.toBe(0); - expect(invalidAttempt.stderr).toContain("producer attempt is invalid"); - const futureAttempt = runManagedImageCandidateRestore(restoreCandidates.run ?? "", { - OPENCLAW_AMD64_ATTEMPT: "3", - }); - expect(futureAttempt.status).not.toBe(0); - expect(futureAttempt.stderr).toContain("producer attempt is invalid"); - const malformedContract = runManagedImageCandidateRestore(restoreCandidates.run ?? "", { - OPENCLAW_AMD64: "not-base64", - }); - expect(malformedContract.status).not.toBe(0); - expect(malformedContract.stderr).toContain( - "managed image candidate producer output is not canonical base64", - ); - expect(malformedContract.stderr).not.toContain("not-base64"); - const noncanonicalContract = runManagedImageCandidateRestore(restoreCandidates.run ?? "", { - OPENCLAW_AMD64: "TR==", - }); - expect(noncanonicalContract.status).not.toBe(0); - expect(noncanonicalContract.stderr).toContain( - "managed image candidate producer output is not canonical base64", - ); - expect(noncanonicalContract.stderr).not.toContain("TR=="); expect(barrier.run).toContain("expected exactly six managed image candidate artifacts"); + expect(barrier.run).toContain("managed image candidate producer attempt is invalid"); + expect(barrier.run).toContain("expected_attempts+=(\"$expected_attempt\")"); expect(barrier.run).toContain("length == 6"); expect(barrier.run).toContain('([.[].platform] | sort) == ["linux/amd64", "linux/arm64"]'); expect(barrier.run).toContain("([.[].reference] | unique | length) == 6"); @@ -1261,7 +1217,7 @@ fi barrier.run ?? "", (candidates) => candidates.map((candidate) => - candidate.artifact === "managed-image-candidate-7744-2-openclaw-linux-arm64" + candidate.artifact === "managed-image-candidate-7744-openclaw-linux-arm64" ? { ...candidate, contract: { ...candidate.contract, platform: "linux/amd64" }, @@ -1375,13 +1331,11 @@ fi ); expect(runPublicationBarrier(barrier.run ?? "").status).toBe(0); - const reusedKey = "openclaw|linux/amd64"; const mixedAttempts = runPublicationBarrier( barrier.run ?? "", reuseOpenclawAmd64FromAttemptOne, "", { - expectedAttempts: { [reusedKey]: "1" }, publicationCohort: "ghrun-7744-1", }, ); @@ -1400,6 +1354,33 @@ fi expect(wrongRunCohort.dockerCalls).toEqual([]); }); + it.each([0, 3])( + "fails before alias code on producer attempt %s", + (producerAttempt) => { + const barrier = step( + managedPromoter(readWorkflow("managed-images.yaml")), + "Validate complete managed image candidate set", + ); + const invalidAttempt = runPublicationBarrier(barrier.run ?? "", (candidates) => { + const candidate = candidates[0]!; + return [ + { + ...candidate, + contract: { + ...candidate.contract, + run: { id: 7744, attempt: producerAttempt }, + }, + }, + ...candidates.slice(1), + ]; + }); + + expect(invalidAttempt.status).not.toBe(0); + expect(invalidAttempt.stderr).toContain("candidate producer attempt is invalid"); + expect(invalidAttempt.dockerCalls).toEqual([]); + }, + ); + it("stages all multi-platform cohort aliases before moving the sole root pointer (#7744)", () => { const promotion = required( step( From bc6e23277f443550ddf69e36a676433fa5a9ca35 Mon Sep 17 00:00:00 2001 From: Julie Yaunches Date: Wed, 19 Aug 2026 01:43:09 -0400 Subject: [PATCH 11/12] refactor(ci): reuse base image platform workflow Signed-off-by: Julie Yaunches --- .../build-base-image-platform/action.yaml | 3 + .github/workflows/base-image-platform.yaml | 83 +++++ .github/workflows/base-image.yaml | 293 ++++++--------- ...anaged-image-publication-workflow-types.ts | 13 +- .../managed-image-publication-workflow.ts | 63 ++++ test/helpers/vitest-watch-triggers.ts | 9 + ...managed-image-publication-workflow.test.ts | 334 +++++++++--------- test/perl-critical-cve-remediation.test.ts | 9 +- test/vitest-watch-triggers.test.ts | 7 + 9 files changed, 465 insertions(+), 349 deletions(-) create mode 100644 .github/workflows/base-image-platform.yaml diff --git a/.github/actions/build-base-image-platform/action.yaml b/.github/actions/build-base-image-platform/action.yaml index 7d2b46ae565..419d9833da1 100644 --- a/.github/actions/build-base-image-platform/action.yaml +++ b/.github/actions/build-base-image-platform/action.yaml @@ -5,6 +5,9 @@ name: build-base-image-platform description: Build and publish one immutable base image digest for a platform. outputs: + digest: + description: Exact digest for the requested platform. + value: ${{ steps.build.outputs.digest }} amd64-digest: description: Exact amd64 digest when this action built the amd64 lane. value: ${{ steps.job-output.outputs.amd64_digest }} diff --git a/.github/workflows/base-image-platform.yaml b/.github/workflows/base-image-platform.yaml new file mode 100644 index 00000000000..56c8aad26ad --- /dev/null +++ b/.github/workflows/base-image-platform.yaml @@ -0,0 +1,83 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +name: Build base image platform + +on: + workflow_call: + inputs: + agent: + description: Agent identifier for the base-image build. + required: true + type: string + arch: + description: Artifact architecture identifier. + required: true + type: string + platform: + description: Docker platform to build. + required: true + type: string + runner: + description: GitHub-hosted runner for the target architecture. + required: true + type: string + dockerfile: + description: Path to the base-image Dockerfile. + required: true + type: string + image: + description: Image repository relative to GHCR. + required: true + type: string + openclaw-version: + description: Optional OpenClaw version build argument. + required: false + type: string + default: "" + outputs: + digest: + description: Exact digest for the requested platform. + value: ${{ jobs.build.outputs.digest }} + secrets: + registry_password: + description: GHCR publication credential. + required: true + +permissions: + contents: read + packages: write + +jobs: + build: + name: Build ${{ inputs.agent }} base image (${{ inputs.arch }}) + runs-on: ${{ inputs.runner }} + timeout-minutes: 60 + permissions: + contents: read + packages: write + outputs: + digest: ${{ steps.platform.outputs.digest }} + steps: + - name: Checkout + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + + - name: Build and publish platform digest + id: platform + uses: ./.github/actions/build-base-image-platform + with: + agent: ${{ inputs.agent }} + arch: ${{ inputs.arch }} + platform: ${{ inputs.platform }} + dockerfile: ${{ inputs.dockerfile }} + image: ${{ inputs.image }} + registry: ghcr.io + registry-username: ${{ github.actor }} + registry-password: ${{ secrets.registry_password }} + openclaw-version: ${{ inputs.openclaw-version }} + metadata-tags: | + type=raw,value=latest,enable=${{ github.ref == 'refs/heads/main' }} + type=ref,event=tag + type=sha,prefix=,format=short diff --git a/.github/workflows/base-image.yaml b/.github/workflows/base-image.yaml index 496b5e8562b..9ee8d603e7d 100644 --- a/.github/workflows/base-image.yaml +++ b/.github/workflows/base-image.yaml @@ -21,6 +21,7 @@ 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/base-image-platform.yaml" - ".github/workflows/managed-images.yaml" - "test/e2e/live/managed-image-activation-e2e.test.ts" - "test/e2e/live/managed-image-activation-e2e-helpers.ts" @@ -125,66 +126,40 @@ jobs: if: github.repository == 'NVIDIA/NemoClaw' needs: - reviewed-npm-audit - runs-on: ubuntu-24.04 - timeout-minutes: 60 - outputs: - digest: ${{ steps.platform.outputs.amd64-digest }} - steps: - - name: Checkout - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - with: - persist-credentials: false - - - name: Build and publish platform digest - id: platform - uses: ./.github/actions/build-base-image-platform - with: - agent: openclaw - arch: amd64 - platform: linux/amd64 - dockerfile: Dockerfile.base - image: nvidia/nemoclaw/sandbox-base - registry: ${{ env.REGISTRY }} - registry-username: ${{ github.actor }} - registry-password: ${{ secrets.GITHUB_TOKEN }} - openclaw-version: ${{ inputs.openclaw_version }} - metadata-tags: | - type=raw,value=latest,enable=${{ github.ref == 'refs/heads/main' }} - type=ref,event=tag - type=sha,prefix=,format=short + permissions: + contents: read + packages: write + uses: ./.github/workflows/base-image-platform.yaml + with: + agent: openclaw + arch: amd64 + platform: linux/amd64 + runner: ubuntu-24.04 + dockerfile: Dockerfile.base + image: nvidia/nemoclaw/sandbox-base + openclaw-version: ${{ inputs.openclaw_version }} + secrets: + registry_password: ${{ secrets.GITHUB_TOKEN }} build-openclaw-arm64: name: Build OpenClaw base image (arm64) if: github.repository == 'NVIDIA/NemoClaw' needs: - reviewed-npm-audit - runs-on: ubuntu-24.04-arm - timeout-minutes: 60 - outputs: - digest: ${{ steps.platform.outputs.arm64-digest }} - steps: - - name: Checkout - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - with: - persist-credentials: false - - - name: Build and publish platform digest - id: platform - uses: ./.github/actions/build-base-image-platform - with: - agent: openclaw - arch: arm64 - platform: linux/arm64 - dockerfile: Dockerfile.base - image: nvidia/nemoclaw/sandbox-base - registry: ${{ env.REGISTRY }} - registry-username: ${{ github.actor }} - registry-password: ${{ secrets.GITHUB_TOKEN }} - openclaw-version: ${{ inputs.openclaw_version }} - metadata-tags: | - type=raw,value=latest,enable=${{ github.ref == 'refs/heads/main' }} - type=ref,event=tag - type=sha,prefix=,format=short + permissions: + contents: read + packages: write + uses: ./.github/workflows/base-image-platform.yaml + with: + agent: openclaw + arch: arm64 + platform: linux/arm64 + runner: ubuntu-24.04-arm + dockerfile: Dockerfile.base + image: nvidia/nemoclaw/sandbox-base + openclaw-version: ${{ inputs.openclaw_version }} + secrets: + registry_password: ${{ secrets.GITHUB_TOKEN }} # The complete Perl suite approaches the image-job timeout under QEMU arm64 emulation. # Build each sibling image on native architecture runners and publish only immutable platform digests. @@ -194,112 +169,76 @@ jobs: if: github.repository == 'NVIDIA/NemoClaw' needs: - reviewed-npm-audit - runs-on: ubuntu-24.04 - timeout-minutes: 60 - outputs: - digest: ${{ steps.platform.outputs.amd64-digest }} - steps: - - name: Checkout - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - with: - persist-credentials: false - - - name: Build and publish platform digest - id: platform - uses: ./.github/actions/build-base-image-platform - with: - agent: hermes - arch: amd64 - platform: linux/amd64 - dockerfile: agents/hermes/Dockerfile.base - image: nvidia/nemoclaw/hermes-sandbox-base - registry: ${{ env.REGISTRY }} - registry-username: ${{ github.actor }} - registry-password: ${{ secrets.GITHUB_TOKEN }} + permissions: + contents: read + packages: write + uses: ./.github/workflows/base-image-platform.yaml + with: + agent: hermes + arch: amd64 + platform: linux/amd64 + runner: ubuntu-24.04 + dockerfile: agents/hermes/Dockerfile.base + image: nvidia/nemoclaw/hermes-sandbox-base + secrets: + registry_password: ${{ secrets.GITHUB_TOKEN }} build-hermes-arm64: name: Build Hermes base image (arm64) if: github.repository == 'NVIDIA/NemoClaw' needs: - reviewed-npm-audit - runs-on: ubuntu-24.04-arm - timeout-minutes: 60 - outputs: - digest: ${{ steps.platform.outputs.arm64-digest }} - steps: - - name: Checkout - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - with: - persist-credentials: false - - - name: Build and publish platform digest - id: platform - uses: ./.github/actions/build-base-image-platform - with: - agent: hermes - arch: arm64 - platform: linux/arm64 - dockerfile: agents/hermes/Dockerfile.base - image: nvidia/nemoclaw/hermes-sandbox-base - registry: ${{ env.REGISTRY }} - registry-username: ${{ github.actor }} - registry-password: ${{ secrets.GITHUB_TOKEN }} + permissions: + contents: read + packages: write + uses: ./.github/workflows/base-image-platform.yaml + with: + agent: hermes + arch: arm64 + platform: linux/arm64 + runner: ubuntu-24.04-arm + dockerfile: agents/hermes/Dockerfile.base + image: nvidia/nemoclaw/hermes-sandbox-base + secrets: + registry_password: ${{ secrets.GITHUB_TOKEN }} build-dcode-amd64: name: Build Deep Agents Code base image (amd64) if: github.repository == 'NVIDIA/NemoClaw' needs: - reviewed-npm-audit - runs-on: ubuntu-24.04 - timeout-minutes: 60 - outputs: - digest: ${{ steps.platform.outputs.amd64-digest }} - steps: - - name: Checkout - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - with: - persist-credentials: false - - - name: Build and publish platform digest - id: platform - uses: ./.github/actions/build-base-image-platform - with: - agent: langchain-deepagents-code - arch: amd64 - platform: linux/amd64 - dockerfile: agents/langchain-deepagents-code/Dockerfile.base - image: nvidia/nemoclaw/langchain-deepagents-code-sandbox-base - registry: ${{ env.REGISTRY }} - registry-username: ${{ github.actor }} - registry-password: ${{ secrets.GITHUB_TOKEN }} + permissions: + contents: read + packages: write + uses: ./.github/workflows/base-image-platform.yaml + with: + agent: langchain-deepagents-code + arch: amd64 + platform: linux/amd64 + runner: ubuntu-24.04 + dockerfile: agents/langchain-deepagents-code/Dockerfile.base + image: nvidia/nemoclaw/langchain-deepagents-code-sandbox-base + secrets: + registry_password: ${{ secrets.GITHUB_TOKEN }} build-dcode-arm64: name: Build Deep Agents Code base image (arm64) if: github.repository == 'NVIDIA/NemoClaw' needs: - reviewed-npm-audit - runs-on: ubuntu-24.04-arm - timeout-minutes: 60 - outputs: - digest: ${{ steps.platform.outputs.arm64-digest }} - steps: - - name: Checkout - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - with: - persist-credentials: false - - - name: Build and publish platform digest - id: platform - uses: ./.github/actions/build-base-image-platform - with: - agent: langchain-deepagents-code - arch: arm64 - platform: linux/arm64 - dockerfile: agents/langchain-deepagents-code/Dockerfile.base - image: nvidia/nemoclaw/langchain-deepagents-code-sandbox-base - registry: ${{ env.REGISTRY }} - registry-username: ${{ github.actor }} - registry-password: ${{ secrets.GITHUB_TOKEN }} + permissions: + contents: read + packages: write + uses: ./.github/workflows/base-image-platform.yaml + with: + agent: langchain-deepagents-code + arch: arm64 + platform: linux/arm64 + runner: ubuntu-24.04-arm + dockerfile: agents/langchain-deepagents-code/Dockerfile.base + image: nvidia/nemoclaw/langchain-deepagents-code-sandbox-base + secrets: + registry_password: ${{ secrets.GITHUB_TOKEN }} # Pi is a candidate agent runtime: this workflow publishes its base image so # CI can validate exact digests, while Pi stays out of the shipped @@ -309,56 +248,38 @@ jobs: if: github.repository == 'NVIDIA/NemoClaw' needs: - reviewed-npm-audit - runs-on: ubuntu-24.04 - timeout-minutes: 60 - outputs: - digest: ${{ steps.platform.outputs.amd64-digest }} - steps: - - name: Checkout - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - with: - persist-credentials: false - - - name: Build and publish platform digest - id: platform - uses: ./.github/actions/build-base-image-platform - with: - agent: pi - arch: amd64 - platform: linux/amd64 - dockerfile: agents/pi/Dockerfile.base - image: nvidia/nemoclaw/pi-sandbox-base - registry: ${{ env.REGISTRY }} - registry-username: ${{ github.actor }} - registry-password: ${{ secrets.GITHUB_TOKEN }} + permissions: + contents: read + packages: write + uses: ./.github/workflows/base-image-platform.yaml + with: + agent: pi + arch: amd64 + platform: linux/amd64 + runner: ubuntu-24.04 + dockerfile: agents/pi/Dockerfile.base + image: nvidia/nemoclaw/pi-sandbox-base + secrets: + registry_password: ${{ secrets.GITHUB_TOKEN }} build-pi-arm64: name: Build Pi base image (arm64) if: github.repository == 'NVIDIA/NemoClaw' needs: - reviewed-npm-audit - runs-on: ubuntu-24.04-arm - timeout-minutes: 60 - outputs: - digest: ${{ steps.platform.outputs.arm64-digest }} - steps: - - name: Checkout - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - with: - persist-credentials: false - - - name: Build and publish platform digest - id: platform - uses: ./.github/actions/build-base-image-platform - with: - agent: pi - arch: arm64 - platform: linux/arm64 - dockerfile: agents/pi/Dockerfile.base - image: nvidia/nemoclaw/pi-sandbox-base - registry: ${{ env.REGISTRY }} - registry-username: ${{ github.actor }} - registry-password: ${{ secrets.GITHUB_TOKEN }} + permissions: + contents: read + packages: write + uses: ./.github/workflows/base-image-platform.yaml + with: + agent: pi + arch: arm64 + platform: linux/arm64 + runner: ubuntu-24.04-arm + dockerfile: agents/pi/Dockerfile.base + image: nvidia/nemoclaw/pi-sandbox-base + secrets: + registry_password: ${{ secrets.GITHUB_TOKEN }} build-and-push-pi: name: Build and push Pi base image diff --git a/test/helpers/managed-image-publication-workflow-types.ts b/test/helpers/managed-image-publication-workflow-types.ts index 8ae3b607bac..72958afb3b3 100644 --- a/test/helpers/managed-image-publication-workflow-types.ts +++ b/test/helpers/managed-image-publication-workflow-types.ts @@ -41,6 +41,7 @@ export type Job = { outputs?: Record; permissions?: Record; "runs-on"?: string; + secrets?: Record | "inherit"; steps?: Step[]; strategy?: { "fail-fast"?: boolean; @@ -67,7 +68,17 @@ export type Workflow = { paths?: string[]; }; workflow_call?: { - inputs?: Record; + inputs?: Record< + string, + { + default?: string | number | boolean; + description?: string; + required?: boolean; + type?: string; + } + >; + outputs?: Record; + secrets?: Record; }; }; permissions?: Record; diff --git a/test/helpers/managed-image-publication-workflow.ts b/test/helpers/managed-image-publication-workflow.ts index 80bfcafd37a..28a585da97c 100644 --- a/test/helpers/managed-image-publication-workflow.ts +++ b/test/helpers/managed-image-publication-workflow.ts @@ -10,6 +10,69 @@ import type { Action, Job, Step, Workflow } from "./managed-image-publication-wo export const repoRoot = path.resolve(import.meta.dirname, "../.."); +export const baseImagePublishers = [ + { + agent: "hermes", + displayName: "Hermes", + dockerfile: "agents/hermes/Dockerfile.base", + image: "nvidia/nemoclaw/hermes-sandbox-base", + job: "build-and-push-hermes", + amd64Job: "build-hermes-amd64", + arm64Job: "build-hermes-arm64", + }, + { + agent: "langchain-deepagents-code", + displayName: "Deep Agents Code", + dockerfile: "agents/langchain-deepagents-code/Dockerfile.base", + image: "nvidia/nemoclaw/langchain-deepagents-code-sandbox-base", + job: "build-and-push-dcode", + amd64Job: "build-dcode-amd64", + arm64Job: "build-dcode-arm64", + }, + { + agent: "openclaw", + displayName: "OpenClaw", + dockerfile: "Dockerfile.base", + image: "nvidia/nemoclaw/sandbox-base", + job: "build-and-push-openclaw", + amd64Job: "build-openclaw-amd64", + arm64Job: "build-openclaw-arm64", + }, + { + agent: "pi", + displayName: "Pi", + dockerfile: "agents/pi/Dockerfile.base", + image: "nvidia/nemoclaw/pi-sandbox-base", + job: "build-and-push-pi", + amd64Job: "build-pi-amd64", + arm64Job: "build-pi-arm64", + }, +] as const; + +export const baseImagePlatformCallers = baseImagePublishers.flatMap( + (publisher) => + [ + { + ...publisher, + arch: "amd64", + job: publisher.amd64Job, + openclawVersion: + publisher.agent === "openclaw" ? "${{ inputs.openclaw_version }}" : undefined, + platform: "linux/amd64", + runner: "ubuntu-24.04", + }, + { + ...publisher, + arch: "arm64", + job: publisher.arm64Job, + openclawVersion: + publisher.agent === "openclaw" ? "${{ inputs.openclaw_version }}" : undefined, + platform: "linux/arm64", + runner: "ubuntu-24.04-arm", + }, + ] as const, +); + export function readWorkflow(file: string): Workflow { return YAML.parse( fs.readFileSync(path.join(repoRoot, ".github", "workflows", file), "utf8"), diff --git a/test/helpers/vitest-watch-triggers.ts b/test/helpers/vitest-watch-triggers.ts index b1632c0e5cb..2c6d661f8d3 100644 --- a/test/helpers/vitest-watch-triggers.ts +++ b/test/helpers/vitest-watch-triggers.ts @@ -120,6 +120,15 @@ export const vitestWatchTriggerPatterns: VitestWatchTriggerPattern[] = [ "test/openclaw-dependency-review.test.ts", ), }, + { + pattern: /(?:^|\/)\.github\/workflows\/base-image-platform\.yaml$/, + testsToRun: runTests( + "test/dcode-base-image-workflow.test.ts", + "test/managed-image-publication-workflow.test.ts", + "test/perl-critical-cve-remediation.test.ts", + "test/pi-candidate-runtime-artifacts.test.ts", + ), + }, { pattern: /(?:^|\/)scripts\/checks\/validate-managed-base-index\.sh$/, testsToRun: runTests("test/validate-managed-base-index.test.ts"), diff --git a/test/managed-image-publication-workflow.test.ts b/test/managed-image-publication-workflow.test.ts index 4466a09d957..4263bc373ec 100644 --- a/test/managed-image-publication-workflow.test.ts +++ b/test/managed-image-publication-workflow.test.ts @@ -19,6 +19,8 @@ import { } from "./helpers/managed-image-publication-barrier"; import { publicationBoundaryErrors } from "./helpers/managed-image-publication-workflow-boundary"; import { + baseImagePlatformCallers, + baseImagePublishers, managedPromoter, managedPublisher, readAction, @@ -107,126 +109,107 @@ describe("complete managed-image publication workflow", () => { } }); - it.each([ - { - agent: "hermes", - contractInput: "hermes-base-contract-base64", - displayName: "Hermes", - image: "nvidia/nemoclaw/hermes-sandbox-base", - job: "build-and-push-hermes", - amd64Job: "build-hermes-amd64", - arm64Job: "build-hermes-arm64", - }, - { - agent: "langchain-deepagents-code", - contractInput: "dcode-base-contract-base64", - displayName: "Deep Agents Code", - image: "nvidia/nemoclaw/langchain-deepagents-code-sandbox-base", - job: "build-and-push-dcode", - amd64Job: "build-dcode-amd64", - arm64Job: "build-dcode-arm64", - }, - { - agent: "openclaw", - contractInput: "openclaw-base-contract-base64", - displayName: "OpenClaw", - image: "nvidia/nemoclaw/sandbox-base", - job: "build-and-push-openclaw", - amd64Job: "build-openclaw-amd64", - arm64Job: "build-openclaw-arm64", - }, - ] as const)( - "starts the $agent publisher after exact base contracts without canceling release tags (#7744)", - (expectedPublisher) => { - 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(JSON.stringify(managedWorkflow)).not.toContain("config.plugins?.installs?.[id]"); - const validationRun = - step(managedPublisher(managedWorkflow), "Validate exact managed image before promotion") - .run ?? ""; - expect(validationRun).not.toContain('path.join(projectsRoot, entry.name, "package.json")'); - const channelGuardEnd = validationRun.indexOf("managed OpenClaw channel"); - const channelGuardStart = validationRun.lastIndexOf("for (const id of [", channelGuardEnd); - expect(channelGuardStart).toBeGreaterThan(-1); - expect(validationRun.slice(channelGuardStart, channelGuardEnd)).toContain('"googlechat",'); - const weakenedWorkflow = structuredClone(managedWorkflow); - const weakenedValidation = step( - managedPublisher(weakenedWorkflow), - "Validate exact managed image before promotion", - ); - weakenedValidation.run = weakenedValidation.run?.replace( - "!fs.lstatSync(manifestPath).isFile()", - "false", - ); - expect(publicationBoundaryErrors(baseWorkflow, weakenedWorkflow)).toContain( - "exact managed image validation is missing lstatSync(manifestPath).isFile()", - ); - const projectRootWeakenedWorkflow = structuredClone(managedWorkflow); - const projectRootWeakenedValidation = step( - managedPublisher(projectRootWeakenedWorkflow), - "Validate exact managed image before promotion", - ); - projectRootWeakenedValidation.run = projectRootWeakenedValidation.run?.replace( - 'path.join(nodeModulesRoot, ...name.split("/"))', - "", - ); - expect(publicationBoundaryErrors(baseWorkflow, projectRootWeakenedWorkflow)).toContain( - 'exact managed image validation is missing path.join(nodeModulesRoot, ...name.split("/"))', - ); - expect(publisher).toMatchObject({ - needs: [ - "build-and-push-hermes", - "build-and-push-dcode", - "build-and-push-openclaw", - "reviewed-npm-audit", - ], - permissions: { - contents: "read", - packages: "write", - }, - uses: "./.github/workflows/managed-images.yaml", - }); + it("starts managed publication after exact base contracts without canceling release tags (#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(JSON.stringify(managedWorkflow)).not.toContain("config.plugins?.installs?.[id]"); + const validationRun = + step(managedPublisher(managedWorkflow), "Validate exact managed image before promotion") + .run ?? ""; + expect(validationRun).not.toContain('path.join(projectsRoot, entry.name, "package.json")'); + const channelGuardEnd = validationRun.indexOf("managed OpenClaw channel"); + const channelGuardStart = validationRun.lastIndexOf("for (const id of [", channelGuardEnd); + expect(channelGuardStart).toBeGreaterThan(-1); + expect(validationRun.slice(channelGuardStart, channelGuardEnd)).toContain('"googlechat",'); + const weakenedWorkflow = structuredClone(managedWorkflow); + const weakenedValidation = step( + managedPublisher(weakenedWorkflow), + "Validate exact managed image before promotion", + ); + weakenedValidation.run = weakenedValidation.run?.replace( + "!fs.lstatSync(manifestPath).isFile()", + "false", + ); + expect(publicationBoundaryErrors(baseWorkflow, weakenedWorkflow)).toContain( + "exact managed image validation is missing lstatSync(manifestPath).isFile()", + ); + const projectRootWeakenedWorkflow = structuredClone(managedWorkflow); + const projectRootWeakenedValidation = step( + managedPublisher(projectRootWeakenedWorkflow), + "Validate exact managed image before promotion", + ); + projectRootWeakenedValidation.run = projectRootWeakenedValidation.run?.replace( + 'path.join(nodeModulesRoot, ...name.split("/"))', + "", + ); + expect(publicationBoundaryErrors(baseWorkflow, projectRootWeakenedWorkflow)).toContain( + 'exact managed image validation is missing path.join(nodeModulesRoot, ...name.split("/"))', + ); + expect(publisher).toMatchObject({ + needs: [ + "build-and-push-hermes", + "build-and-push-dcode", + "build-and-push-openclaw", + "reviewed-npm-audit", + ], + 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 reviewedAudit = required( - baseWorkflow.jobs?.["reviewed-npm-audit"], - "base-image workflow is missing the reviewed npm audit", - ); - expect(reviewedAudit).toMatchObject({ - if: "github.repository == 'NVIDIA/NemoClaw'", - permissions: { contents: "read" }, - "runs-on": "ubuntu-latest", - "timeout-minutes": 15, - }); - expect(step(reviewedAudit, "Checkout").with?.["persist-credentials"]).toBe(false); - expect(step(reviewedAudit, "Audit reviewed production npm graphs")).toMatchObject({ - uses: "./.github/actions/ci-reviewed-npm-audit", - with: { - "report-dir": "artifacts/reviewed-npm-audit", - "target-root": "${{ github.workspace }}", - }, + expect(publisher.with).toMatchObject({ + "dcode-base-contract-base64": needsOutput("build-and-push-dcode", "contract-base64"), + "hermes-base-contract-base64": needsOutput("build-and-push-hermes", "contract-base64"), + "openclaw-base-contract-base64": needsOutput( + "build-and-push-openclaw", + "contract-base64", + ), }); - const basePublisher = required( + const reviewedAudit = required( + baseWorkflow.jobs?.["reviewed-npm-audit"], + "base-image workflow is missing the reviewed npm audit", + ); + expect(reviewedAudit).toMatchObject({ + if: "github.repository == 'NVIDIA/NemoClaw'", + permissions: { contents: "read" }, + "runs-on": "ubuntu-latest", + "timeout-minutes": 15, + }); + expect(step(reviewedAudit, "Checkout").with?.["persist-credentials"]).toBe(false); + expect(step(reviewedAudit, "Audit reviewed production npm graphs")).toMatchObject({ + uses: "./.github/actions/ci-reviewed-npm-audit", + with: { + "report-dir": "artifacts/reviewed-npm-audit", + "target-root": "${{ github.workspace }}", + }, + }); + }); + + it.each(baseImagePublishers)( + "binds the $agent manifest to reusable architecture callers (#9529)", + (expectedPublisher) => { + const baseWorkflow = readWorkflow("base-image.yaml"); + const publisher = required( baseWorkflow.jobs?.[expectedPublisher.job], `base-image workflow is missing ${expectedPublisher.agent} manifest publisher`, ); - expect(basePublisher.needs).toEqual([ + expect(publisher.needs).toEqual([ expectedPublisher.amd64Job, expectedPublisher.arm64Job, "reviewed-npm-audit", ]); - const manifest = step( - basePublisher, - "Publish validated multi-platform manifest", - "base-image workflow", - ); - expect(manifest).toMatchObject({ + expect( + step(publisher, "Publish validated multi-platform manifest", "base-image workflow"), + ).toMatchObject({ uses: "./.github/actions/publish-base-image-manifest", with: { agent: expectedPublisher.agent, @@ -235,75 +218,106 @@ describe("complete managed-image publication workflow", () => { "display-name": expectedPublisher.displayName, image: expectedPublisher.image, registry: "${{ env.REGISTRY }}", - "registry-username": "${{ github.actor }}", "registry-password": "${{ secrets.GITHUB_TOKEN }}", + "registry-username": "${{ github.actor }}", }, }); - expect(basePublisher.outputs?.["contract-base64"]).toBe( + expect(publisher.outputs?.["contract-base64"]).toBe( "${{ steps.publish.outputs.contract-base64 }}", ); - expect(publisher.with?.[expectedPublisher.contractInput]).toBe( - needsOutput(expectedPublisher.job, "contract-base64"), - ); - expect( - step(basePublisher, "Checkout", "base-image workflow").with?.["persist-credentials"], - ).toBe(false); - const amd64Job = required( - baseWorkflow.jobs?.[expectedPublisher.amd64Job], - `base-image workflow is missing native ${expectedPublisher.agent} amd64`, - ); - expect(amd64Job).toMatchObject({ - needs: ["reviewed-npm-audit"], - outputs: { digest: "${{ steps.platform.outputs.amd64-digest }}" }, - "runs-on": "ubuntu-24.04", + expect(step(publisher, "Checkout", "base-image workflow").with).toMatchObject({ + "persist-credentials": false, }); - expect(amd64Job.strategy).toBeUndefined(); - expect( - step(amd64Job, "Build and publish platform digest", "base-image workflow").with, - ).toMatchObject({ - agent: expectedPublisher.agent, - arch: "amd64", - platform: "linux/amd64", - }); - const arm64Job = required( - baseWorkflow.jobs?.[expectedPublisher.arm64Job], - `base-image workflow is missing native ${expectedPublisher.agent} arm64`, + }, + ); + + it.each(baseImagePlatformCallers)( + "calls one reusable builder for $agent $arch while retaining its digest output (#9529)", + (expectedCaller) => { + const caller = required( + readWorkflow("base-image.yaml").jobs?.[expectedCaller.job], + `base-image workflow is missing ${expectedCaller.agent} ${expectedCaller.arch}`, ); - expect(arm64Job).toMatchObject({ + expect(caller).toMatchObject({ + if: "github.repository == 'NVIDIA/NemoClaw'", needs: ["reviewed-npm-audit"], - outputs: { digest: "${{ steps.platform.outputs.arm64-digest }}" }, - "runs-on": "ubuntu-24.04-arm", - }); - expect(arm64Job.strategy).toBeUndefined(); - expect( - step(arm64Job, "Build and publish platform digest", "base-image workflow").with, - ).toMatchObject({ - agent: expectedPublisher.agent, - arch: "arm64", - platform: "linux/arm64", + permissions: { contents: "read", packages: "write" }, + secrets: { registry_password: "${{ secrets.GITHUB_TOKEN }}" }, + uses: "./.github/workflows/base-image-platform.yaml", + with: { + agent: expectedCaller.agent, + arch: expectedCaller.arch, + dockerfile: expectedCaller.dockerfile, + image: expectedCaller.image, + platform: expectedCaller.platform, + runner: expectedCaller.runner, + }, }); + expect(caller.strategy).toBeUndefined(); + expect(caller.steps).toBeUndefined(); + expect(caller["runs-on"]).toBeUndefined(); + expect(caller.with?.["openclaw-version"]).toBe(expectedCaller.openclawVersion); }, ); + + it("owns the platform build body in one reusable workflow (#9529)", () => { + const workflow = readWorkflow("base-image-platform.yaml"); + const call = required(workflow.on?.workflow_call, "platform workflow is not reusable"); + const builder = required(workflow.jobs?.build, "platform workflow is missing its builder"); + expect(Object.keys(call.inputs ?? {}).sort()).toEqual([ + "agent", + "arch", + "dockerfile", + "image", + "openclaw-version", + "platform", + "runner", + ]); + expect(call.outputs?.digest?.value).toBe("${{ jobs.build.outputs.digest }}"); + expect(call.secrets?.registry_password?.required).toBe(true); + expect(builder).toMatchObject({ + permissions: { contents: "read", packages: "write" }, + "runs-on": "${{ inputs.runner }}", + "timeout-minutes": 60, + }); + expect(builder.outputs?.digest).toContain("steps.platform.outputs"); + expect(step(builder, "Checkout", "base-image platform workflow").with).toMatchObject({ + "persist-credentials": false, + }); + expect( + step(builder, "Build and publish platform digest", "base-image platform workflow"), + ).toMatchObject({ + id: "platform", + uses: "./.github/actions/build-base-image-platform", + with: { + agent: "${{ inputs.agent }}", + arch: "${{ inputs.arch }}", + dockerfile: "${{ inputs.dockerfile }}", + image: "${{ inputs.image }}", + "openclaw-version": "${{ inputs.openclaw-version }}", + platform: "${{ inputs.platform }}", + registry: "ghcr.io", + "registry-password": "${{ secrets.registry_password }}", + "registry-username": "${{ github.actor }}", + }, + }); + }); it("exports one architecture-specific digest from every native platform action (#9529)", () => { const action = readAction("build-base-image-platform"); expect(action.outputs).toMatchObject({ + digest: { value: "${{ steps.build.outputs.digest }}" }, "amd64-digest": { value: "${{ steps.job-output.outputs.amd64_digest }}" }, "arm64-digest": { value: "${{ steps.job-output.outputs.arm64_digest }}" }, }); - expect(new Set(Object.values(action.outputs ?? {}).map(({ value }) => value)).size).toBe(2); - const exportDigest = step({ steps: action.runs?.steps }, "Export platform digest", "build-base-image-platform action"); - expect(exportDigest.run).toContain('printf \'%s_digest=%s\\n\' "$ARCH" "$DIGEST"'); - }); - it("binds each Pi digest to its non-matrix producer (#9529)", () => { - const publisher = required( - readWorkflow("base-image.yaml").jobs?.["build-and-push-pi"], - "base-image workflow is missing the Pi manifest publisher", + expect(action.outputs?.["amd64-digest"]?.value).not.toBe( + action.outputs?.["arm64-digest"]?.value, ); - expect(publisher.needs).toEqual(["build-pi-amd64", "build-pi-arm64", "reviewed-npm-audit"]); - expect(step(publisher, "Publish validated multi-platform manifest").with).toMatchObject({ - "amd64-digest": needsOutput("build-pi-amd64", "digest"), - "arm64-digest": needsOutput("build-pi-arm64", "digest"), - }); + const exportDigest = step( + { steps: action.runs?.steps }, + "Export platform digest", + "build-base-image-platform action", + ); + expect(exportDigest.run).toContain('printf \'%s_digest=%s\\n\' "$ARCH" "$DIGEST"'); }); it("builds and exercises every shipped agent from an exact PR image before merge (#7744)", () => { const workflow = readWorkflow("managed-images.yaml"); diff --git a/test/perl-critical-cve-remediation.test.ts b/test/perl-critical-cve-remediation.test.ts index 88b8c89e66a..ebd7e4b0ccb 100644 --- a/test/perl-critical-cve-remediation.test.ts +++ b/test/perl-critical-cve-remediation.test.ts @@ -13,6 +13,10 @@ const baseImageWorkflow = fs.readFileSync( path.join(repoRoot, ".github", "workflows", "base-image.yaml"), "utf8", ); +const baseImagePlatformWorkflow = fs.readFileSync( + path.join(repoRoot, ".github", "workflows", "base-image-platform.yaml"), + "utf8", +); const packageBuilder = fs.readFileSync( path.join(repoRoot, "scripts", "security", "build-perl-security-packages.sh"), "utf8", @@ -417,10 +421,11 @@ env "\${perl_test_env[@]}" bash -c 'test "$NEMOCLAW_PERL_SKIP_RAW_ICMPV4_TESTS" it.each(Array.from(managedImages, (value) => [value]))( "builds both architectures and $name from the PR head (#7338)", (image) => { - expect(baseImageWorkflow).toContain("runs-on: ubuntu-24.04"); - expect(baseImageWorkflow).toContain("runs-on: ubuntu-24.04-arm"); + 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(baseImagePlatformWorkflow).toContain("runs-on: ${{ inputs.runner }}"); expect(baseImageWorkflow, image.name).toContain(`dockerfile: ${image.dockerfile}`); }, diff --git a/test/vitest-watch-triggers.test.ts b/test/vitest-watch-triggers.test.ts index 9c1906f4118..c545cd9380c 100644 --- a/test/vitest-watch-triggers.test.ts +++ b/test/vitest-watch-triggers.test.ts @@ -55,6 +55,7 @@ const OPAQUE_INPUTS = [ "scripts/setup-jetson.sh", "tools/e2e/contracts/v1/jetson-dispatch.json", ".github/workflows/base-image.yaml", + ".github/workflows/base-image-platform.yaml", "scripts/export-managed-base-image-contract.sh", "scripts/checks/validate-managed-base-index.sh", "scripts/e2e/sanitize-trace-timing.py", @@ -149,6 +150,12 @@ describe("Vitest opaque-input watch triggers", () => { "test/dcode-base-image-workflow.test.ts", "test/openclaw-dependency-review.test.ts", ]); + expect(triggeredBy(".github/workflows/base-image-platform.yaml")).toEqual([ + "test/dcode-base-image-workflow.test.ts", + "test/managed-image-publication-workflow.test.ts", + "test/perl-critical-cve-remediation.test.ts", + "test/pi-candidate-runtime-artifacts.test.ts", + ]); expect(triggeredBy("scripts/checks/validate-managed-base-index.sh")).toEqual([ "test/validate-managed-base-index.test.ts", ]); From e0a4ee78fb5a84831d06f86bb618f2e97814fa88 Mon Sep 17 00:00:00 2001 From: Carlos Villela Date: Tue, 18 Aug 2026 23:25:39 -0700 Subject: [PATCH 12/12] test(ci): clarify publication diagnostics Signed-off-by: Carlos Villela --- .github/actions/publish-base-image-manifest/action.yaml | 2 +- test/managed-image-publication-workflow.test.ts | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/actions/publish-base-image-manifest/action.yaml b/.github/actions/publish-base-image-manifest/action.yaml index 25607d58a91..af169aa817e 100644 --- a/.github/actions/publish-base-image-manifest/action.yaml +++ b/.github/actions/publish-base-image-manifest/action.yaml @@ -94,7 +94,7 @@ runs: set -euo pipefail contract="$RUNNER_TEMP/managed-base-contract/contract.json" if [ ! -f "$contract" ] || [ -L "$contract" ]; then - echo "ERROR: managed base image contract is missing or unsafe." >&2 + echo "ERROR: managed base image contract is missing or is a symbolic link." >&2 exit 1 fi contract_size="$(wc -c < "$contract" | tr -d '[:space:]')" diff --git a/test/managed-image-publication-workflow.test.ts b/test/managed-image-publication-workflow.test.ts index 4263bc373ec..3f7db3248d0 100644 --- a/test/managed-image-publication-workflow.test.ts +++ b/test/managed-image-publication-workflow.test.ts @@ -260,7 +260,7 @@ describe("complete managed-image publication workflow", () => { }, ); - it("owns the platform build body in one reusable workflow (#9529)", () => { + it("defines the platform build in one reusable workflow (#9529)", () => { const workflow = readWorkflow("base-image-platform.yaml"); const call = required(workflow.on?.workflow_call, "platform workflow is not reusable"); const builder = required(workflow.jobs?.build, "platform workflow is missing its builder"); @@ -1369,7 +1369,7 @@ fi }); it.each([0, 3])( - "fails before alias code on producer attempt %s", + "rejects producer attempt %s without publishing aliases", (producerAttempt) => { const barrier = step( managedPromoter(readWorkflow("managed-images.yaml")),