diff --git a/.github/actions/build-base-image-platform/action.yaml b/.github/actions/build-base-image-platform/action.yaml new file mode 100644 index 00000000000..fd2b3979ba3 --- /dev/null +++ b/.github/actions/build-base-image-platform/action.yaml @@ -0,0 +1,131 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +name: build-base-image-platform +description: Build and publish one immutable base image digest for a platform. + +inputs: + agent: + description: Agent identifier used for build arguments and artifact names. + required: true + arch: + description: Artifact architecture identifier. + required: true + platform: + description: Docker platform to build. + required: true + dockerfile: + description: Path to the base image Dockerfile. + required: true + image: + description: Image repository relative to the registry. + required: true + registry: + description: Container registry host. + required: true + registry-username: + description: Registry login user. + required: true + registry-password: + description: Registry login credential. + required: true + openclaw-version: + description: Optional OpenClaw version build argument. + required: false + default: "" + metadata-tags: + description: Optional docker/metadata-action tag rules. + required: false + default: "" + +runs: + using: composite + steps: + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4.2.0 + + - name: Log in to GHCR + uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0 + with: + registry: ${{ inputs.registry }} + username: ${{ inputs.registry-username }} + password: ${{ inputs.registry-password }} + + - name: Extract metadata + id: meta + uses: docker/metadata-action@dc802804100637a589fabce1cb79ff13a1411302 # v6.2.0 + env: + DOCKER_METADATA_SHORT_SHA_LENGTH: 8 + with: + images: ${{ inputs.registry }}/${{ inputs.image }} + tags: ${{ inputs.metadata-tags }} + + - name: Validate production Docker build args + id: production-build-args + shell: bash + env: + AGENT: ${{ inputs.agent }} + OPENCLAW_VERSION_INPUT: ${{ inputs.openclaw-version }} + run: | + set -euo pipefail + build_args=() + openclaw_build_arg="" + if [ "$AGENT" = "openclaw" ] && [ -n "${OPENCLAW_VERSION_INPUT}" ]; then + openclaw_build_arg="OPENCLAW_VERSION=${OPENCLAW_VERSION_INPUT}" + build_args+=(--build-arg "$openclaw_build_arg") + fi + if [ "${#build_args[@]}" -gt 0 ]; then + scripts/check-production-build-args.sh "${build_args[@]}" + else + scripts/check-production-build-args.sh + fi + if [ "$AGENT" = "openclaw" ] && [ -n "${OPENCLAW_VERSION_INPUT}" ]; then + if [[ "$OPENCLAW_VERSION_INPUT" == *$'\r'* || "$OPENCLAW_VERSION_INPUT" == *$'\n'* ]]; then + echo "ERROR: OpenClaw version must not contain CR or LF characters." >&2 + exit 1 + fi + if [[ ! "$OPENCLAW_VERSION_INPUT" =~ ^[0-9]+([.][0-9]+)*$ ]]; then + echo "ERROR: OpenClaw version must contain one or more decimal integers separated by periods (for example, 2026.6.10)." >&2 + exit 1 + fi + fi + printf 'openclaw_build_arg=%s\n' "$openclaw_build_arg" >> "$GITHUB_OUTPUT" + + - name: Build and push platform digest + id: build + uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0 + with: + context: . + file: ${{ inputs.dockerfile }} + platforms: ${{ inputs.platform }} + labels: ${{ steps.meta.outputs.labels }} + outputs: type=image,name=${{ inputs.registry }}/${{ inputs.image }},push-by-digest=true,name-canonical=true,push=true + cache-from: type=registry,ref=${{ inputs.registry }}/${{ inputs.image }}:buildcache-${{ inputs.arch }} + cache-to: type=registry,ref=${{ inputs.registry }}/${{ inputs.image }}:buildcache-${{ inputs.arch }},mode=max + build-args: ${{ steps.production-build-args.outputs.openclaw_build_arg }} + + - name: Export platform digest + shell: bash + env: + ARCH: ${{ inputs.arch }} + DIGEST: ${{ steps.build.outputs.digest }} + run: | + set -euo pipefail + if [[ ! "$ARCH" =~ ^(amd64|arm64)$ ]]; then + echo "ERROR: unsupported platform architecture: $ARCH" >&2 + exit 1 + fi + if [[ ! "$DIGEST" =~ ^sha256:[0-9a-f]{64}$ ]]; then + echo "ERROR: build did not return a valid sha256 digest: $DIGEST" >&2 + exit 1 + fi + mkdir -p "$RUNNER_TEMP/digests" + touch "$RUNNER_TEMP/digests/${ARCH}-${DIGEST#sha256:}" + + - name: Upload platform digest + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: ${{ inputs.agent }}-base-digest-${{ github.run_id }}-${{ github.run_attempt }}-${{ inputs.arch }} + path: ${{ runner.temp }}/digests/* + if-no-files-found: error + retention-days: 1 diff --git a/.github/actions/ci-build-typecheck/action.yaml b/.github/actions/ci-build-typecheck/action.yaml index 6e1b803a661..c9d73e0a540 100644 --- a/.github/actions/ci-build-typecheck/action.yaml +++ b/.github/actions/ci-build-typecheck/action.yaml @@ -12,12 +12,13 @@ runs: with: node-version: "22" cache: npm + cache-dependency-path: | + package-lock.json + nemoclaw/package-lock.json - name: Install dependencies shell: bash - run: | - npm install --ignore-scripts - cd nemoclaw && npm install --ignore-scripts + run: bash "$GITHUB_ACTION_PATH/../ci-install-dependencies.sh" - name: Build TypeScript plugin shell: bash diff --git a/.github/actions/ci-cli-coverage-shard/action.yaml b/.github/actions/ci-cli-coverage-shard/action.yaml index bbe67d018d7..63aedad52ac 100644 --- a/.github/actions/ci-cli-coverage-shard/action.yaml +++ b/.github/actions/ci-cli-coverage-shard/action.yaml @@ -56,6 +56,9 @@ runs: with: node-version: "22" cache: npm + cache-dependency-path: | + package-lock.json + nemoclaw/package-lock.json - name: Install pinned Pi search tools shell: bash @@ -98,9 +101,7 @@ runs: - name: Install dependencies shell: bash - run: | - npm install --ignore-scripts - cd nemoclaw && npm install --ignore-scripts + run: bash "$GITHUB_ACTION_PATH/../ci-install-dependencies.sh" - name: Validate changed live E2E mock parity if: ${{ inputs.shard == '1' }} diff --git a/.github/actions/ci-install-dependencies.sh b/.github/actions/ci-install-dependencies.sh new file mode 100755 index 00000000000..2dc2d1bff3f --- /dev/null +++ b/.github/actions/ci-install-dependencies.sh @@ -0,0 +1,8 @@ +#!/usr/bin/env bash +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +set -euo pipefail + +npm ci --ignore-scripts +npm --prefix nemoclaw ci --ignore-scripts diff --git a/.github/actions/ci-installer-integration/action.yaml b/.github/actions/ci-installer-integration/action.yaml index f166edd92e6..b8d4487932d 100644 --- a/.github/actions/ci-installer-integration/action.yaml +++ b/.github/actions/ci-installer-integration/action.yaml @@ -12,12 +12,13 @@ runs: with: node-version: "22" cache: npm + cache-dependency-path: | + package-lock.json + nemoclaw/package-lock.json - name: Install dependencies shell: bash - run: | - npm install --ignore-scripts - cd nemoclaw && npm install --ignore-scripts + run: bash "$GITHUB_ACTION_PATH/../ci-install-dependencies.sh" - name: Build installer integration artifacts shell: bash diff --git a/.github/actions/ci-plugin-coverage/action.yaml b/.github/actions/ci-plugin-coverage/action.yaml index 8b238b50ee7..9af48f0b627 100644 --- a/.github/actions/ci-plugin-coverage/action.yaml +++ b/.github/actions/ci-plugin-coverage/action.yaml @@ -12,12 +12,13 @@ runs: with: node-version: "22" cache: npm + cache-dependency-path: | + package-lock.json + nemoclaw/package-lock.json - name: Install dependencies shell: bash - run: | - npm install --ignore-scripts - cd nemoclaw && npm install --ignore-scripts + run: bash "$GITHUB_ACTION_PATH/../ci-install-dependencies.sh" - name: Run plugin coverage shell: bash diff --git a/.github/actions/ci-reviewed-npm-audit/action.yaml b/.github/actions/ci-reviewed-npm-audit/action.yaml index e674c4bedf8..7b8693c3f5f 100644 --- a/.github/actions/ci-reviewed-npm-audit/action.yaml +++ b/.github/actions/ci-reviewed-npm-audit/action.yaml @@ -44,3 +44,4 @@ runs: name: reviewed-npm-audit path: ${{ inputs.target-root }}/${{ inputs.report-dir }}/*.json if-no-files-found: error + retention-days: 14 diff --git a/.github/actions/ci-wechat-runtime-audit/action.yaml b/.github/actions/ci-wechat-runtime-audit/action.yaml index 47befd7eb80..de8324c7474 100644 --- a/.github/actions/ci-wechat-runtime-audit/action.yaml +++ b/.github/actions/ci-wechat-runtime-audit/action.yaml @@ -20,14 +20,13 @@ runs: with: node-version: "22.19.0" - - name: Pin production npm + - name: Download and verify production npm shell: bash - run: >- - cd "$RUNNER_TEMP" && - npm install --global npm@10.9.4 - --userconfig /dev/null - --registry https://registry.npmjs.org/ - --ignore-scripts --no-audit --no-fund + env: + NEMOCLAW_REVIEWED_NPM_VERSION: "10.9.4" + NEMOCLAW_REVIEWED_NPM_INTEGRITY: >- + sha512-OnUG836FwboQIbqtefDNlyR0gTHzIfwRfE3DuiNewBvnMnWEpB0VEXwBlFVgqpNzIgYo/MHh3d2Hel/pszapAA== + run: '"$GITHUB_ACTION_PATH/../ci-reviewed-npm-audit/verify-and-install-npm.sh"' - name: Audit locked WeChat runtime graph shell: bash diff --git a/.github/actions/publish-base-image-manifest/action.yaml b/.github/actions/publish-base-image-manifest/action.yaml new file mode 100644 index 00000000000..984157b9ec3 --- /dev/null +++ b/.github/actions/publish-base-image-manifest/action.yaml @@ -0,0 +1,74 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +name: publish-base-image-manifest +description: Validate platform digests and publish one multi-platform base image manifest. + +inputs: + agent: + description: Agent identifier stored in the managed base image contract. + required: true + display-name: + description: Agent name used in validation errors. + required: true + image: + description: Image repository relative to the registry. + required: true + registry: + description: Container registry host. + required: true + registry-username: + description: Registry login user. + required: true + registry-password: + description: Registry login credential. + required: true + +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: Set up Docker Buildx + uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4.2.0 + + - name: Log in to GHCR + uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0 + with: + registry: ${{ inputs.registry }} + username: ${{ inputs.registry-username }} + password: ${{ inputs.registry-password }} + + - name: Extract metadata + id: meta + uses: docker/metadata-action@dc802804100637a589fabce1cb79ff13a1411302 # v6.2.0 + env: + DOCKER_METADATA_SHORT_SHA_LENGTH: 8 + with: + images: ${{ inputs.registry }}/${{ inputs.image }} + tags: | + type=raw,value=latest,enable=${{ github.ref == 'refs/heads/main' }} + type=ref,event=tag + type=sha,prefix=,format=short + + - name: Create and verify multi-platform manifest + id: manifest + shell: bash + env: + AGENT: ${{ inputs.agent }} + DISPLAY_NAME: ${{ inputs.display-name }} + IMAGE: ${{ inputs.registry }}/${{ inputs.image }} + TAGS: ${{ steps.meta.outputs.tags }} + run: bash "$GITHUB_ACTION_PATH/publish.sh" + - name: Upload managed base image contract + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: managed-base-${{ github.run_id }}-${{ github.run_attempt }}-${{ inputs.agent }} + path: ${{ runner.temp }}/managed-base-contract/contract.json + if-no-files-found: error + retention-days: 1 diff --git a/.github/actions/publish-base-image-manifest/publish.sh b/.github/actions/publish-base-image-manifest/publish.sh new file mode 100755 index 00000000000..07c6ed0ca80 --- /dev/null +++ b/.github/actions/publish-base-image-manifest/publish.sh @@ -0,0 +1,123 @@ +#!/usr/bin/env bash +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +set -euo pipefail +: "${AGENT:?}" +: "${DISPLAY_NAME:?}" +: "${IMAGE:?}" +: "${TAGS:?}" +: "${RUNNER_TEMP:?}" +: "${GITHUB_RUN_ID:?}" +: "${GITHUB_RUN_ATTEMPT:?}" +: "${GITHUB_SHA:?}" +: "${GITHUB_OUTPUT:?}" +shopt -s nullglob +digest_files=("$RUNNER_TEMP"/digests/*) +if [ "${#digest_files[@]}" -ne 2 ]; then + echo "ERROR: expected exactly two platform digests, found ${#digest_files[@]}." >&2 + exit 1 +fi + +declare -A seen_arches=() +declare -A source_digests=() +sources=() +for digest_file in "${digest_files[@]}"; do + digest_artifact="$(basename "$digest_file")" + if [[ ! "$digest_artifact" =~ ^(amd64|arm64)-([0-9a-f]{64})$ ]]; then + echo "ERROR: invalid platform digest artifact: $digest_artifact" >&2 + exit 1 + fi + expected_arch="${BASH_REMATCH[1]}" + digest="${BASH_REMATCH[2]}" + if [ -n "${seen_arches[$expected_arch]:-}" ]; then + echo "ERROR: duplicate platform digest for linux/$expected_arch." >&2 + exit 1 + fi + source="$IMAGE@sha256:$digest" + source_platform="$( + scripts/checks/retry-docker-imagetools-inspect.sh "$source" \ + --format '{{.Image.OS}}/{{.Image.Architecture}}' + )" + if [ "$source_platform" != "linux/$expected_arch" ]; then + echo "ERROR: digest for linux/$expected_arch resolves to $source_platform." >&2 + exit 1 + fi + seen_arches["$expected_arch"]=1 + source_digests["linux/$expected_arch"]="sha256:$digest" + sources+=("$source") +done +if [ "${seen_arches[amd64]:-0}" -ne 1 ] || [ "${seen_arches[arm64]:-0}" -ne 1 ]; then + echo "ERROR: expected one validated digest for linux/amd64 and linux/arm64." >&2 + exit 1 +fi + +mapfile -t tags <<<"$TAGS" +tag_args=() +for tag in "${tags[@]}"; do + if [ -n "$tag" ]; then + tag_args+=(--tag "$tag") + fi +done +if [ "${#tag_args[@]}" -eq 0 ]; then + echo "ERROR: metadata did not produce any publication tags." >&2 + exit 1 +fi + +candidate_tag="$IMAGE:base-candidate-${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}" +candidate_metadata="$RUNNER_TEMP/$AGENT-base-candidate-metadata.json" +docker buildx imagetools create \ + --tag "$candidate_tag" \ + --metadata-file "$candidate_metadata" \ + "${sources[@]}" +digest="$(jq -er '.["containerimage.descriptor"].digest' "$candidate_metadata")" +if [[ ! "$digest" =~ ^sha256:[0-9a-f]{64}$ ]]; then + echo "ERROR: expected one staged $DISPLAY_NAME base digest." >&2 + exit 1 +fi +reference="$IMAGE@$digest" +actual_platforms="$( + scripts/checks/retry-docker-imagetools-inspect.sh "$reference" --raw \ + | jq -r '.manifests[] | select(.platform.os == "linux") | .platform.architecture' \ + | sort -u \ + | paste -sd, - +)" +if [ "$actual_platforms" != "amd64,arm64" ]; then + echo "ERROR: staged manifest has unexpected platforms: $actual_platforms" >&2 + exit 1 +fi +platform_digests_json="$( + scripts/checks/validate-managed-base-index.sh \ + "$reference" \ + "${source_digests['linux/amd64']}" \ + "${source_digests['linux/arm64']}" +)" +declare -A platform_digests=() +for platform in linux/amd64 linux/arm64; do + platform_digests["$platform"]="$( + jq -er --arg platform "$platform" '.[$platform]' <<<"$platform_digests_json" + )" +done + +scripts/export-managed-base-image-contract.sh \ + "$AGENT" \ + "$IMAGE" \ + "$digest" \ + "${platform_digests['linux/amd64']}" \ + "${platform_digests['linux/arm64']}" \ + "$GITHUB_SHA" \ + "$GITHUB_RUN_ID" \ + "$GITHUB_RUN_ATTEMPT" \ + "$RUNNER_TEMP/managed-base-contract/contract.json" + +publication_metadata="$RUNNER_TEMP/$AGENT-base-publication-metadata.json" +docker buildx imagetools create \ + "${tag_args[@]}" \ + --metadata-file "$publication_metadata" \ + "$reference" +published_digest="$(jq -er '.["containerimage.descriptor"].digest' "$publication_metadata")" +if [ "$published_digest" != "$digest" ]; then + echo "ERROR: published $DISPLAY_NAME base digest differs from the validated candidate." >&2 + exit 1 +fi +printf 'digest=%s\n' "$digest" >>"$GITHUB_OUTPUT" diff --git a/.github/workflows/base-image.yaml b/.github/workflows/base-image.yaml index bf0e2284c54..67932f1c7ba 100644 --- a/.github/workflows/base-image.yaml +++ b/.github/workflows/base-image.yaml @@ -23,6 +23,8 @@ on: - ".github/workflows/base-image.yaml" - ".github/workflows/managed-images.yaml" - ".github/actions/ci-reviewed-npm-audit/**" + - ".github/actions/build-base-image-platform/**" + - ".github/actions/publish-base-image-manifest/**" - ".dockerignore" # Complete managed-image inputs. Keep these reviewed families synchronized # with tools/e2e/base-image-publication.mts. @@ -136,96 +138,26 @@ jobs: steps: - name: Checkout uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - - - name: Set up Docker Buildx - uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4.2.0 - - - name: Log in to GHCR - uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0 with: - registry: ${{ env.REGISTRY }} - username: ${{ github.actor }} - password: ${{ secrets.GITHUB_TOKEN }} + persist-credentials: false - - name: Extract metadata - id: meta - uses: docker/metadata-action@dc802804100637a589fabce1cb79ff13a1411302 # v6.2.0 - env: - DOCKER_METADATA_SHORT_SHA_LENGTH: 8 + - name: Build and publish platform digest + uses: ./.github/actions/build-base-image-platform with: - images: ${{ env.REGISTRY }}/${{ matrix.image }} - tags: | + agent: ${{ matrix.agent }} + arch: ${{ matrix.arch }} + platform: ${{ matrix.platform }} + dockerfile: ${{ matrix.dockerfile }} + image: ${{ matrix.image }} + 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 - - name: Validate production Docker build args - id: production-build-args - env: - AGENT: ${{ matrix.agent }} - OPENCLAW_VERSION_INPUT: ${{ inputs.openclaw_version }} - run: | - set -euo pipefail - build_args=() - openclaw_build_arg="" - if [ "$AGENT" = "openclaw" ] && [ -n "${OPENCLAW_VERSION_INPUT}" ]; then - openclaw_build_arg="OPENCLAW_VERSION=${OPENCLAW_VERSION_INPUT}" - build_args+=(--build-arg "$openclaw_build_arg") - fi - if [ "${#build_args[@]}" -gt 0 ]; then - scripts/check-production-build-args.sh "${build_args[@]}" - else - scripts/check-production-build-args.sh - fi - if [ "$AGENT" = "openclaw" ] && [ -n "${OPENCLAW_VERSION_INPUT}" ]; then - if [[ "$OPENCLAW_VERSION_INPUT" == *$'\r'* || "$OPENCLAW_VERSION_INPUT" == *$'\n'* ]]; then - echo "ERROR: OpenClaw version must not contain CR or LF characters." >&2 - exit 1 - fi - if [[ ! "$OPENCLAW_VERSION_INPUT" =~ ^[0-9]+([.][0-9]+)*$ ]]; then - echo "ERROR: OpenClaw version must be a whole decimal dotted version (for example, 2026.6.10)." >&2 - exit 1 - fi - fi - printf 'openclaw_build_arg=%s\n' "$openclaw_build_arg" >> "$GITHUB_OUTPUT" - - - name: Build and push platform digest - id: build - uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0 - with: - context: . - file: ${{ matrix.dockerfile }} - platforms: ${{ matrix.platform }} - labels: ${{ steps.meta.outputs.labels }} - outputs: type=image,name=${{ env.REGISTRY }}/${{ matrix.image }},push-by-digest=true,name-canonical=true,push=true - cache-from: type=registry,ref=${{ env.REGISTRY }}/${{ matrix.image }}:buildcache-${{ matrix.arch }} - cache-to: type=registry,ref=${{ env.REGISTRY }}/${{ matrix.image }}:buildcache-${{ matrix.arch }},mode=max - build-args: ${{ steps.production-build-args.outputs.openclaw_build_arg }} - - - name: Export platform digest - env: - ARCH: ${{ matrix.arch }} - DIGEST: ${{ steps.build.outputs.digest }} - run: | - set -euo pipefail - if [[ ! "$ARCH" =~ ^(amd64|arm64)$ ]]; then - echo "ERROR: unsupported platform architecture: $ARCH" >&2 - exit 1 - fi - if [[ ! "$DIGEST" =~ ^sha256:[0-9a-f]{64}$ ]]; then - echo "ERROR: build did not return a valid sha256 digest: $DIGEST" >&2 - exit 1 - fi - mkdir -p "$RUNNER_TEMP/digests" - touch "$RUNNER_TEMP/digests/${ARCH}-${DIGEST#sha256:}" - - - name: Upload platform digest - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 - with: - name: openclaw-base-digest-${{ github.run_id }}-${{ github.run_attempt }}-${{ matrix.arch }} - path: ${{ runner.temp }}/digests/* - if-no-files-found: error - retention-days: 1 # 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. @@ -258,92 +190,21 @@ jobs: steps: - name: Checkout uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - - - name: Set up Docker Buildx - uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4.2.0 - - - name: Log in to GHCR - uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0 - with: - registry: ${{ env.REGISTRY }} - username: ${{ github.actor }} - password: ${{ secrets.GITHUB_TOKEN }} - - - name: Extract metadata - id: meta - uses: docker/metadata-action@dc802804100637a589fabce1cb79ff13a1411302 # v6.2.0 - env: - DOCKER_METADATA_SHORT_SHA_LENGTH: 8 with: - images: ${{ env.REGISTRY }}/${{ matrix.image }} - - - name: Validate production Docker build args - id: production-build-args - env: - AGENT: ${{ matrix.agent }} - OPENCLAW_VERSION_INPUT: ${{ inputs.openclaw_version }} - run: | - set -euo pipefail - build_args=() - openclaw_build_arg="" - if [ "$AGENT" = "openclaw" ] && [ -n "${OPENCLAW_VERSION_INPUT}" ]; then - openclaw_build_arg="OPENCLAW_VERSION=${OPENCLAW_VERSION_INPUT}" - build_args+=(--build-arg "$openclaw_build_arg") - fi - if [ "${#build_args[@]}" -gt 0 ]; then - scripts/check-production-build-args.sh "${build_args[@]}" - else - scripts/check-production-build-args.sh - fi - if [ "$AGENT" = "openclaw" ] && [ -n "${OPENCLAW_VERSION_INPUT}" ]; then - if [[ "$OPENCLAW_VERSION_INPUT" == *$'\r'* || "$OPENCLAW_VERSION_INPUT" == *$'\n'* ]]; then - echo "ERROR: OpenClaw version must not contain CR or LF characters." >&2 - exit 1 - fi - if [[ ! "$OPENCLAW_VERSION_INPUT" =~ ^[0-9]+([.][0-9]+)*$ ]]; then - echo "ERROR: OpenClaw version must be a whole decimal dotted version (for example, 2026.6.10)." >&2 - exit 1 - fi - fi - printf 'openclaw_build_arg=%s\n' "$openclaw_build_arg" >> "$GITHUB_OUTPUT" + persist-credentials: false - - name: Build and push platform digest - id: build - uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0 + - name: Build and publish platform digest + uses: ./.github/actions/build-base-image-platform with: - context: . - file: ${{ matrix.dockerfile }} - platforms: ${{ matrix.platform }} - labels: ${{ steps.meta.outputs.labels }} - outputs: type=image,name=${{ env.REGISTRY }}/${{ matrix.image }},push-by-digest=true,name-canonical=true,push=true - cache-from: type=registry,ref=${{ env.REGISTRY }}/${{ matrix.image }}:buildcache-${{ matrix.arch }} - cache-to: type=registry,ref=${{ env.REGISTRY }}/${{ matrix.image }}:buildcache-${{ matrix.arch }},mode=max - build-args: ${{ steps.production-build-args.outputs.openclaw_build_arg }} - - - name: Export platform digest - env: - ARCH: ${{ matrix.arch }} - DIGEST: ${{ steps.build.outputs.digest }} - run: | - set -euo pipefail - if [[ ! "$ARCH" =~ ^(amd64|arm64)$ ]]; then - echo "ERROR: unsupported platform architecture: $ARCH" >&2 - exit 1 - fi - if [[ ! "$DIGEST" =~ ^sha256:[0-9a-f]{64}$ ]]; then - echo "ERROR: build did not return a valid sha256 digest: $DIGEST" >&2 - exit 1 - fi - mkdir -p "$RUNNER_TEMP/digests" - touch "$RUNNER_TEMP/digests/${ARCH}-${DIGEST#sha256:}" + agent: ${{ matrix.agent }} + arch: ${{ matrix.arch }} + platform: ${{ matrix.platform }} + dockerfile: ${{ matrix.dockerfile }} + image: ${{ matrix.image }} + registry: ${{ env.REGISTRY }} + registry-username: ${{ github.actor }} + registry-password: ${{ secrets.GITHUB_TOKEN }} - - name: Upload platform digest - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 - with: - name: ${{ matrix.agent }}-base-digest-${{ github.run_id }}-${{ github.run_attempt }}-${{ matrix.arch }} - path: ${{ runner.temp }}/digests/* - if-no-files-found: error - retention-days: 1 build-dcode-platforms: name: Build ${{ matrix.display_name }} base image (${{ matrix.arch }}) @@ -373,92 +234,21 @@ jobs: steps: - name: Checkout uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - - - name: Set up Docker Buildx - uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4.2.0 - - - name: Log in to GHCR - uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0 - with: - registry: ${{ env.REGISTRY }} - username: ${{ github.actor }} - password: ${{ secrets.GITHUB_TOKEN }} - - - name: Extract metadata - id: meta - uses: docker/metadata-action@dc802804100637a589fabce1cb79ff13a1411302 # v6.2.0 - env: - DOCKER_METADATA_SHORT_SHA_LENGTH: 8 with: - images: ${{ env.REGISTRY }}/${{ matrix.image }} - - - name: Validate production Docker build args - id: production-build-args - env: - AGENT: ${{ matrix.agent }} - OPENCLAW_VERSION_INPUT: ${{ inputs.openclaw_version }} - run: | - set -euo pipefail - build_args=() - openclaw_build_arg="" - if [ "$AGENT" = "openclaw" ] && [ -n "${OPENCLAW_VERSION_INPUT}" ]; then - openclaw_build_arg="OPENCLAW_VERSION=${OPENCLAW_VERSION_INPUT}" - build_args+=(--build-arg "$openclaw_build_arg") - fi - if [ "${#build_args[@]}" -gt 0 ]; then - scripts/check-production-build-args.sh "${build_args[@]}" - else - scripts/check-production-build-args.sh - fi - if [ "$AGENT" = "openclaw" ] && [ -n "${OPENCLAW_VERSION_INPUT}" ]; then - if [[ "$OPENCLAW_VERSION_INPUT" == *$'\r'* || "$OPENCLAW_VERSION_INPUT" == *$'\n'* ]]; then - echo "ERROR: OpenClaw version must not contain CR or LF characters." >&2 - exit 1 - fi - if [[ ! "$OPENCLAW_VERSION_INPUT" =~ ^[0-9]+([.][0-9]+)*$ ]]; then - echo "ERROR: OpenClaw version must be a whole decimal dotted version (for example, 2026.6.10)." >&2 - exit 1 - fi - fi - printf 'openclaw_build_arg=%s\n' "$openclaw_build_arg" >> "$GITHUB_OUTPUT" + persist-credentials: false - - name: Build and push platform digest - id: build - uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0 + - name: Build and publish platform digest + uses: ./.github/actions/build-base-image-platform with: - context: . - file: ${{ matrix.dockerfile }} - platforms: ${{ matrix.platform }} - labels: ${{ steps.meta.outputs.labels }} - outputs: type=image,name=${{ env.REGISTRY }}/${{ matrix.image }},push-by-digest=true,name-canonical=true,push=true - cache-from: type=registry,ref=${{ env.REGISTRY }}/${{ matrix.image }}:buildcache-${{ matrix.arch }} - cache-to: type=registry,ref=${{ env.REGISTRY }}/${{ matrix.image }}:buildcache-${{ matrix.arch }},mode=max - build-args: ${{ steps.production-build-args.outputs.openclaw_build_arg }} + agent: ${{ matrix.agent }} + arch: ${{ matrix.arch }} + platform: ${{ matrix.platform }} + dockerfile: ${{ matrix.dockerfile }} + image: ${{ matrix.image }} + registry: ${{ env.REGISTRY }} + registry-username: ${{ github.actor }} + registry-password: ${{ secrets.GITHUB_TOKEN }} - - name: Export platform digest - env: - ARCH: ${{ matrix.arch }} - DIGEST: ${{ steps.build.outputs.digest }} - run: | - set -euo pipefail - if [[ ! "$ARCH" =~ ^(amd64|arm64)$ ]]; then - echo "ERROR: unsupported platform architecture: $ARCH" >&2 - exit 1 - fi - if [[ ! "$DIGEST" =~ ^sha256:[0-9a-f]{64}$ ]]; then - echo "ERROR: build did not return a valid sha256 digest: $DIGEST" >&2 - exit 1 - fi - mkdir -p "$RUNNER_TEMP/digests" - touch "$RUNNER_TEMP/digests/${ARCH}-${DIGEST#sha256:}" - - - name: Upload platform digest - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 - with: - name: ${{ matrix.agent }}-base-digest-${{ github.run_id }}-${{ github.run_attempt }}-${{ matrix.arch }} - path: ${{ runner.temp }}/digests/* - if-no-files-found: error - retention-days: 1 build-and-push-hermes: name: Build and push Hermes base image @@ -474,160 +264,16 @@ jobs: with: persist-credentials: false - - name: Download platform digests - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 - with: - pattern: hermes-base-digest-${{ github.run_id }}-${{ github.run_attempt }}-* - path: ${{ runner.temp }}/digests - merge-multiple: true - - - name: Set up Docker Buildx - uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4.2.0 - - - name: Log in to GHCR - uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0 + - name: Publish validated multi-platform manifest + uses: ./.github/actions/publish-base-image-manifest with: + agent: hermes + display-name: Hermes + image: nvidia/nemoclaw/hermes-sandbox-base registry: ${{ env.REGISTRY }} - username: ${{ github.actor }} - password: ${{ secrets.GITHUB_TOKEN }} - - - name: Extract metadata - id: meta - uses: docker/metadata-action@dc802804100637a589fabce1cb79ff13a1411302 # v6.2.0 - env: - DOCKER_METADATA_SHORT_SHA_LENGTH: 8 - with: - images: ${{ env.REGISTRY }}/nvidia/nemoclaw/hermes-sandbox-base - tags: | - type=raw,value=latest,enable=${{ github.ref == 'refs/heads/main' }} - type=ref,event=tag - type=sha,prefix=,format=short + registry-username: ${{ github.actor }} + registry-password: ${{ secrets.GITHUB_TOKEN }} - - name: Create and verify multi-platform manifest - id: manifest - env: - AGENT: hermes - IMAGE: ${{ env.REGISTRY }}/nvidia/nemoclaw/hermes-sandbox-base - TAGS: ${{ steps.meta.outputs.tags }} - run: | - set -euo pipefail - shopt -s nullglob - digest_files=("$RUNNER_TEMP"/digests/*) - if [ "${#digest_files[@]}" -ne 2 ]; then - echo "ERROR: expected exactly two platform digests, found ${#digest_files[@]}." >&2 - exit 1 - fi - - declare -A seen_arches=() - declare -A source_digests=() - sources=() - for digest_file in "${digest_files[@]}"; do - digest_artifact="$(basename "$digest_file")" - if [[ ! "$digest_artifact" =~ ^(amd64|arm64)-([0-9a-f]{64})$ ]]; then - echo "ERROR: invalid platform digest artifact: $digest_artifact" >&2 - exit 1 - fi - expected_arch="${BASH_REMATCH[1]}" - digest="${BASH_REMATCH[2]}" - if [ -n "${seen_arches[$expected_arch]:-}" ]; then - echo "ERROR: duplicate platform digest for linux/$expected_arch." >&2 - exit 1 - fi - source="$IMAGE@sha256:$digest" - source_platform="$( - scripts/checks/retry-docker-imagetools-inspect.sh "$source" \ - --format '{{.Image.OS}}/{{.Image.Architecture}}' - )" - if [ "$source_platform" != "linux/$expected_arch" ]; then - echo "ERROR: digest for linux/$expected_arch resolves to $source_platform." >&2 - exit 1 - fi - seen_arches["$expected_arch"]=1 - source_digests["linux/$expected_arch"]="sha256:$digest" - sources+=("$source") - done - if [ "${seen_arches[amd64]:-0}" -ne 1 ] || [ "${seen_arches[arm64]:-0}" -ne 1 ]; then - echo "ERROR: expected one validated digest for linux/amd64 and linux/arm64." >&2 - exit 1 - fi - - mapfile -t tags <<< "$TAGS" - tag_args=() - for tag in "${tags[@]}"; do - if [ -n "$tag" ]; then - tag_args+=(--tag "$tag") - fi - done - if [ "${#tag_args[@]}" -eq 0 ]; then - echo "ERROR: metadata did not produce any publication tags." >&2 - exit 1 - fi - - candidate_tag="$IMAGE:base-candidate-${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}" - candidate_metadata="$RUNNER_TEMP/$AGENT-base-candidate-metadata.json" - docker buildx imagetools create \ - --tag "$candidate_tag" \ - --metadata-file "$candidate_metadata" \ - "${sources[@]}" - digest="$(jq -er '.["containerimage.descriptor"].digest' "$candidate_metadata")" - if [[ ! "$digest" =~ ^sha256:[0-9a-f]{64}$ ]]; then - echo "ERROR: expected one staged Hermes base digest." >&2 - exit 1 - fi - reference="$IMAGE@$digest" - actual_platforms="$( - scripts/checks/retry-docker-imagetools-inspect.sh "$reference" --raw \ - | jq -r '.manifests[] | select(.platform.os == "linux") | .platform.architecture' \ - | sort -u \ - | paste -sd, - - )" - if [ "$actual_platforms" != "amd64,arm64" ]; then - echo "ERROR: staged manifest has unexpected platforms: $actual_platforms" >&2 - exit 1 - fi - platform_digests_json="$( - scripts/checks/validate-managed-base-index.sh \ - "$reference" \ - "${source_digests[linux/amd64]}" \ - "${source_digests[linux/arm64]}" - )" - declare -A platform_digests=() - for platform in linux/amd64 linux/arm64; do - platform_digests["$platform"]="$( - jq -er --arg platform "$platform" '.[$platform]' <<< "$platform_digests_json" - )" - done - - scripts/export-managed-base-image-contract.sh \ - "$AGENT" \ - "$IMAGE" \ - "$digest" \ - "${platform_digests[linux/amd64]}" \ - "${platform_digests[linux/arm64]}" \ - "$GITHUB_SHA" \ - "$GITHUB_RUN_ID" \ - "$GITHUB_RUN_ATTEMPT" \ - "$RUNNER_TEMP/managed-base-contract/contract.json" - - publication_metadata="$RUNNER_TEMP/$AGENT-base-publication-metadata.json" - docker buildx imagetools create \ - "${tag_args[@]}" \ - --metadata-file "$publication_metadata" \ - "$reference" - published_digest="$(jq -er '.["containerimage.descriptor"].digest' "$publication_metadata")" - if [ "$published_digest" != "$digest" ]; then - echo "ERROR: published Hermes base digest differs from the validated candidate." >&2 - exit 1 - fi - printf 'digest=%s\n' "$digest" >> "$GITHUB_OUTPUT" - - - name: Upload managed base image contract - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 - with: - name: managed-base-${{ github.run_id }}-${{ github.run_attempt }}-hermes - path: ${{ runner.temp }}/managed-base-contract/contract.json - if-no-files-found: error - retention-days: 1 build-and-push-dcode: name: Build and push Deep Agents Code base image @@ -643,160 +289,16 @@ jobs: with: persist-credentials: false - - name: Download platform digests - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 - with: - pattern: langchain-deepagents-code-base-digest-${{ github.run_id }}-${{ github.run_attempt }}-* - path: ${{ runner.temp }}/digests - merge-multiple: true - - - name: Set up Docker Buildx - uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4.2.0 - - - name: Log in to GHCR - uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0 + - name: Publish validated multi-platform manifest + uses: ./.github/actions/publish-base-image-manifest with: + agent: langchain-deepagents-code + display-name: Deep Agents Code + image: nvidia/nemoclaw/langchain-deepagents-code-sandbox-base registry: ${{ env.REGISTRY }} - username: ${{ github.actor }} - password: ${{ secrets.GITHUB_TOKEN }} - - - name: Extract metadata - id: meta - uses: docker/metadata-action@dc802804100637a589fabce1cb79ff13a1411302 # v6.2.0 - env: - DOCKER_METADATA_SHORT_SHA_LENGTH: 8 - with: - images: ${{ env.REGISTRY }}/nvidia/nemoclaw/langchain-deepagents-code-sandbox-base - tags: | - type=raw,value=latest,enable=${{ github.ref == 'refs/heads/main' }} - type=ref,event=tag - type=sha,prefix=,format=short - - - name: Create and verify multi-platform manifest - id: manifest - env: - AGENT: langchain-deepagents-code - IMAGE: ${{ env.REGISTRY }}/nvidia/nemoclaw/langchain-deepagents-code-sandbox-base - TAGS: ${{ steps.meta.outputs.tags }} - run: | - set -euo pipefail - shopt -s nullglob - digest_files=("$RUNNER_TEMP"/digests/*) - if [ "${#digest_files[@]}" -ne 2 ]; then - echo "ERROR: expected exactly two platform digests, found ${#digest_files[@]}." >&2 - exit 1 - fi - - declare -A seen_arches=() - declare -A source_digests=() - sources=() - for digest_file in "${digest_files[@]}"; do - digest_artifact="$(basename "$digest_file")" - if [[ ! "$digest_artifact" =~ ^(amd64|arm64)-([0-9a-f]{64})$ ]]; then - echo "ERROR: invalid platform digest artifact: $digest_artifact" >&2 - exit 1 - fi - expected_arch="${BASH_REMATCH[1]}" - digest="${BASH_REMATCH[2]}" - if [ -n "${seen_arches[$expected_arch]:-}" ]; then - echo "ERROR: duplicate platform digest for linux/$expected_arch." >&2 - exit 1 - fi - source="$IMAGE@sha256:$digest" - source_platform="$( - scripts/checks/retry-docker-imagetools-inspect.sh "$source" \ - --format '{{.Image.OS}}/{{.Image.Architecture}}' - )" - if [ "$source_platform" != "linux/$expected_arch" ]; then - echo "ERROR: digest for linux/$expected_arch resolves to $source_platform." >&2 - exit 1 - fi - seen_arches["$expected_arch"]=1 - source_digests["linux/$expected_arch"]="sha256:$digest" - sources+=("$source") - done - if [ "${seen_arches[amd64]:-0}" -ne 1 ] || [ "${seen_arches[arm64]:-0}" -ne 1 ]; then - echo "ERROR: expected one validated digest for linux/amd64 and linux/arm64." >&2 - exit 1 - fi - - mapfile -t tags <<< "$TAGS" - tag_args=() - for tag in "${tags[@]}"; do - if [ -n "$tag" ]; then - tag_args+=(--tag "$tag") - fi - done - if [ "${#tag_args[@]}" -eq 0 ]; then - echo "ERROR: metadata did not produce any publication tags." >&2 - exit 1 - fi - - candidate_tag="$IMAGE:base-candidate-${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}" - candidate_metadata="$RUNNER_TEMP/$AGENT-base-candidate-metadata.json" - docker buildx imagetools create \ - --tag "$candidate_tag" \ - --metadata-file "$candidate_metadata" \ - "${sources[@]}" - digest="$(jq -er '.["containerimage.descriptor"].digest' "$candidate_metadata")" - if [[ ! "$digest" =~ ^sha256:[0-9a-f]{64}$ ]]; then - echo "ERROR: expected one staged Deep Agents Code base digest." >&2 - exit 1 - fi - reference="$IMAGE@$digest" - actual_platforms="$( - scripts/checks/retry-docker-imagetools-inspect.sh "$reference" --raw \ - | jq -r '.manifests[] | select(.platform.os == "linux") | .platform.architecture' \ - | sort -u \ - | paste -sd, - - )" - if [ "$actual_platforms" != "amd64,arm64" ]; then - echo "ERROR: staged manifest has unexpected platforms: $actual_platforms" >&2 - exit 1 - fi - platform_digests_json="$( - scripts/checks/validate-managed-base-index.sh \ - "$reference" \ - "${source_digests[linux/amd64]}" \ - "${source_digests[linux/arm64]}" - )" - declare -A platform_digests=() - for platform in linux/amd64 linux/arm64; do - platform_digests["$platform"]="$( - jq -er --arg platform "$platform" '.[$platform]' <<< "$platform_digests_json" - )" - done + registry-username: ${{ github.actor }} + registry-password: ${{ secrets.GITHUB_TOKEN }} - scripts/export-managed-base-image-contract.sh \ - "$AGENT" \ - "$IMAGE" \ - "$digest" \ - "${platform_digests[linux/amd64]}" \ - "${platform_digests[linux/arm64]}" \ - "$GITHUB_SHA" \ - "$GITHUB_RUN_ID" \ - "$GITHUB_RUN_ATTEMPT" \ - "$RUNNER_TEMP/managed-base-contract/contract.json" - - publication_metadata="$RUNNER_TEMP/$AGENT-base-publication-metadata.json" - docker buildx imagetools create \ - "${tag_args[@]}" \ - --metadata-file "$publication_metadata" \ - "$reference" - published_digest="$(jq -er '.["containerimage.descriptor"].digest' "$publication_metadata")" - if [ "$published_digest" != "$digest" ]; then - echo "ERROR: published Deep Agents Code base digest differs from the validated candidate." >&2 - exit 1 - fi - printf 'digest=%s\n' "$digest" >> "$GITHUB_OUTPUT" - - - name: Upload managed base image contract - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 - with: - name: managed-base-${{ github.run_id }}-${{ github.run_attempt }}-langchain-deepagents-code - path: ${{ runner.temp }}/managed-base-contract/contract.json - if-no-files-found: error - retention-days: 1 # Preserve the established required-check name while making tag publication # contingent on both native platform builds succeeding. @@ -814,160 +316,16 @@ jobs: with: persist-credentials: false - - name: Download platform digests - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 - with: - pattern: openclaw-base-digest-${{ github.run_id }}-${{ github.run_attempt }}-* - path: ${{ runner.temp }}/digests - merge-multiple: true - - - name: Set up Docker Buildx - uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4.2.0 - - - name: Log in to GHCR - uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0 + - name: Publish validated multi-platform manifest + uses: ./.github/actions/publish-base-image-manifest with: + agent: openclaw + display-name: OpenClaw + image: nvidia/nemoclaw/sandbox-base registry: ${{ env.REGISTRY }} - username: ${{ github.actor }} - password: ${{ secrets.GITHUB_TOKEN }} - - - name: Extract metadata - id: meta - uses: docker/metadata-action@dc802804100637a589fabce1cb79ff13a1411302 # v6.2.0 - env: - DOCKER_METADATA_SHORT_SHA_LENGTH: 8 - with: - images: ${{ env.REGISTRY }}/nvidia/nemoclaw/sandbox-base - tags: | - type=raw,value=latest,enable=${{ github.ref == 'refs/heads/main' }} - type=ref,event=tag - type=sha,prefix=,format=short - - - name: Create and verify multi-platform manifest - id: manifest - env: - AGENT: openclaw - IMAGE: ${{ env.REGISTRY }}/nvidia/nemoclaw/sandbox-base - TAGS: ${{ steps.meta.outputs.tags }} - run: | - set -euo pipefail - shopt -s nullglob - digest_files=("$RUNNER_TEMP"/digests/*) - if [ "${#digest_files[@]}" -ne 2 ]; then - echo "ERROR: expected exactly two platform digests, found ${#digest_files[@]}." >&2 - exit 1 - fi - - declare -A seen_arches=() - declare -A source_digests=() - sources=() - for digest_file in "${digest_files[@]}"; do - digest_artifact="$(basename "$digest_file")" - if [[ ! "$digest_artifact" =~ ^(amd64|arm64)-([0-9a-f]{64})$ ]]; then - echo "ERROR: invalid platform digest artifact: $digest_artifact" >&2 - exit 1 - fi - expected_arch="${BASH_REMATCH[1]}" - digest="${BASH_REMATCH[2]}" - if [ -n "${seen_arches[$expected_arch]:-}" ]; then - echo "ERROR: duplicate platform digest for linux/$expected_arch." >&2 - exit 1 - fi - source="$IMAGE@sha256:$digest" - source_platform="$( - scripts/checks/retry-docker-imagetools-inspect.sh "$source" \ - --format '{{.Image.OS}}/{{.Image.Architecture}}' - )" - if [ "$source_platform" != "linux/$expected_arch" ]; then - echo "ERROR: digest for linux/$expected_arch resolves to $source_platform." >&2 - exit 1 - fi - seen_arches["$expected_arch"]=1 - source_digests["linux/$expected_arch"]="sha256:$digest" - sources+=("$source") - done - if [ "${seen_arches[amd64]:-0}" -ne 1 ] || [ "${seen_arches[arm64]:-0}" -ne 1 ]; then - echo "ERROR: expected one validated digest for linux/amd64 and linux/arm64." >&2 - exit 1 - fi - - mapfile -t tags <<< "$TAGS" - tag_args=() - for tag in "${tags[@]}"; do - if [ -n "$tag" ]; then - tag_args+=(--tag "$tag") - fi - done - if [ "${#tag_args[@]}" -eq 0 ]; then - echo "ERROR: metadata did not produce any publication tags." >&2 - exit 1 - fi + registry-username: ${{ github.actor }} + registry-password: ${{ secrets.GITHUB_TOKEN }} - candidate_tag="$IMAGE:base-candidate-${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}" - candidate_metadata="$RUNNER_TEMP/$AGENT-base-candidate-metadata.json" - docker buildx imagetools create \ - --tag "$candidate_tag" \ - --metadata-file "$candidate_metadata" \ - "${sources[@]}" - digest="$(jq -er '.["containerimage.descriptor"].digest' "$candidate_metadata")" - if [[ ! "$digest" =~ ^sha256:[0-9a-f]{64}$ ]]; then - echo "ERROR: expected one staged OpenClaw base digest." >&2 - exit 1 - fi - reference="$IMAGE@$digest" - actual_platforms="$( - scripts/checks/retry-docker-imagetools-inspect.sh "$reference" --raw \ - | jq -r '.manifests[] | select(.platform.os == "linux") | .platform.architecture' \ - | sort -u \ - | paste -sd, - - )" - if [ "$actual_platforms" != "amd64,arm64" ]; then - echo "ERROR: staged manifest has unexpected platforms: $actual_platforms" >&2 - exit 1 - fi - platform_digests_json="$( - scripts/checks/validate-managed-base-index.sh \ - "$reference" \ - "${source_digests[linux/amd64]}" \ - "${source_digests[linux/arm64]}" - )" - declare -A platform_digests=() - for platform in linux/amd64 linux/arm64; do - platform_digests["$platform"]="$( - jq -er --arg platform "$platform" '.[$platform]' <<< "$platform_digests_json" - )" - done - - scripts/export-managed-base-image-contract.sh \ - "$AGENT" \ - "$IMAGE" \ - "$digest" \ - "${platform_digests[linux/amd64]}" \ - "${platform_digests[linux/arm64]}" \ - "$GITHUB_SHA" \ - "$GITHUB_RUN_ID" \ - "$GITHUB_RUN_ATTEMPT" \ - "$RUNNER_TEMP/managed-base-contract/contract.json" - - publication_metadata="$RUNNER_TEMP/$AGENT-base-publication-metadata.json" - docker buildx imagetools create \ - "${tag_args[@]}" \ - --metadata-file "$publication_metadata" \ - "$reference" - published_digest="$(jq -er '.["containerimage.descriptor"].digest' "$publication_metadata")" - if [ "$published_digest" != "$digest" ]; then - echo "ERROR: published OpenClaw base digest differs from the validated candidate." >&2 - exit 1 - fi - printf 'digest=%s\n' "$digest" >> "$GITHUB_OUTPUT" - - - name: Upload managed base image contract - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 - with: - name: managed-base-${{ github.run_id }}-${{ github.run_attempt }}-openclaw - path: ${{ runner.temp }}/managed-base-contract/contract.json - if-no-files-found: error - retention-days: 1 # Consume the three exact base-image contracts in this run. The reusable # publisher promotes no mutable cohort alias until every agent and platform diff --git a/.github/workflows/code-scanning.yaml b/.github/workflows/code-scanning.yaml index 9e6770bbe93..d00a7707b3f 100644 --- a/.github/workflows/code-scanning.yaml +++ b/.github/workflows/code-scanning.yaml @@ -33,6 +33,8 @@ jobs: steps: - name: Checkout uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false - name: Initialize CodeQL uses: github/codeql-action/init@f205ea1c3313d32999d8d6a48b4f6530d4437b38 # v4 @@ -51,15 +53,15 @@ jobs: - name: Checkout uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: - path: source persist-credentials: false + path: source - name: Check out the trusted ShellCheck converter uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: + persist-credentials: false ref: ${{ github.event_name == 'pull_request' && github.event.pull_request.base.sha || github.workflow_sha }} path: trusted-shellcheck-converter - persist-credentials: false sparse-checkout: | scripts/shellcheck-json1-to-sarif.mts sparse-checkout-cone-mode: false diff --git a/.github/workflows/commit-lint.yaml b/.github/workflows/commit-lint.yaml index b7a5d2c483b..d58a79aca9a 100644 --- a/.github/workflows/commit-lint.yaml +++ b/.github/workflows/commit-lint.yaml @@ -21,6 +21,8 @@ jobs: steps: - name: Checkout uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false - name: Setup Node.js uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 diff --git a/.github/workflows/docker-pin-check.yaml b/.github/workflows/docker-pin-check.yaml index e38cf59cbe9..d9a56e984b9 100644 --- a/.github/workflows/docker-pin-check.yaml +++ b/.github/workflows/docker-pin-check.yaml @@ -23,6 +23,8 @@ jobs: steps: - name: Checkout uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false - name: Check Dockerfile base-image pin run: | diff --git a/.github/workflows/docs-cli-parity-pr.yaml b/.github/workflows/docs-cli-parity-pr.yaml index 7954e6b8953..70b5d861260 100644 --- a/.github/workflows/docs-cli-parity-pr.yaml +++ b/.github/workflows/docs-cli-parity-pr.yaml @@ -37,6 +37,8 @@ jobs: steps: - name: Checkout uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false - name: Setup Node.js uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 diff --git a/.github/workflows/docs-links-pr.yaml b/.github/workflows/docs-links-pr.yaml index 3233847fde3..a9819d1206e 100644 --- a/.github/workflows/docs-links-pr.yaml +++ b/.github/workflows/docs-links-pr.yaml @@ -23,6 +23,7 @@ jobs: - name: Checkout uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: + persist-credentials: false fetch-depth: 0 - name: Determine changed documentation files diff --git a/.github/workflows/e2e.yaml b/.github/workflows/e2e.yaml index 094560b4771..0a8abae638a 100644 --- a/.github/workflows/e2e.yaml +++ b/.github/workflows/e2e.yaml @@ -7247,6 +7247,45 @@ jobs: shell: bash run: bash .github/scripts/docker-auth-cleanup.sh + whatsapp-qr-compact: + needs: generate-matrix + if: ${{ (github.event_name != 'workflow_dispatch' || (inputs.jobs == '' && inputs.targets == '')) || contains(format(',{0},', inputs.jobs), ',whatsapp-qr-compact,') || contains(format(',{0},', inputs.targets), ',whatsapp-qr-compact,') }} + runs-on: ubuntu-latest + timeout-minutes: 15 + env: + E2E_JOB: "1" + E2E_TARGET_ID: "whatsapp-qr-compact" + E2E_ARTIFACT_DIR: ${{ github.workspace }}/e2e-artifacts/live/whatsapp-qr-compact + NEMOCLAW_RUN_LIVE_E2E: "1" + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + repository: ${{ inputs.checkout_repository || github.repository }} + ref: ${{ inputs.checkout_sha || github.sha }} + persist-credentials: false + + - *dockerhub-auth + + - name: Prepare E2E workspace + uses: NVIDIA/NemoClaw/.github/actions/prepare-e2e@f6304bc25fc35bfaa441c8c2fbfee38f72805a75 + with: + build-cli: "false" + + - name: Run WhatsApp compact QR code E2E test + run: |- + set -euo pipefail + npx tsx tools/e2e/live-vitest-invocation.mts run --test-path test/e2e/live/whatsapp-qr-compact.test.ts + + - name: Upload WhatsApp compact QR code artifacts + if: always() + uses: NVIDIA/NemoClaw/.github/actions/upload-e2e-artifacts@7768e15eb90d3ee2d33432f481dfe8747e4f6d57 + + - name: Clean up Docker auth + if: always() + shell: bash + run: bash .github/scripts/docker-auth-cleanup.sh + + spark-install: needs: generate-matrix if: ${{ (github.event_name != 'workflow_dispatch' || (inputs.jobs == '' && inputs.targets == '')) || contains(format(',{0},', inputs.jobs), ',spark-install,') || contains(format(',{0},', inputs.targets), ',spark-install,') }} @@ -7387,6 +7426,7 @@ jobs: openclaw-discord-pairing, openclaw-slack-pairing, channels-stop-start, + whatsapp-qr-compact, spark-install, ] if: ${{ always() && github.event_name == 'workflow_dispatch' && inputs.checkout_sha == '' }} diff --git a/.github/workflows/macos-e2e.yaml b/.github/workflows/macos-e2e.yaml index eaa418396f5..214d99376c1 100644 --- a/.github/workflows/macos-e2e.yaml +++ b/.github/workflows/macos-e2e.yaml @@ -29,6 +29,8 @@ jobs: steps: - name: Checkout uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false - name: Setup Node.js uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 @@ -109,3 +111,4 @@ jobs: /tmp/nemoclaw-e2e-*.log ${{ github.workspace }}/e2e-artifacts/live if-no-files-found: ignore + retention-days: 14 diff --git a/.github/workflows/managed-images.yaml b/.github/workflows/managed-images.yaml index f50200db5e7..c66ffa61b82 100644 --- a/.github/workflows/managed-images.yaml +++ b/.github/workflows/managed-images.yaml @@ -41,6 +41,10 @@ permissions: contents: read packages: write +concurrency: + group: managed-images-${{ github.event_name == 'pull_request' && github.event.pull_request.number || github.run_id }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} + env: REGISTRY: ghcr.io @@ -363,6 +367,7 @@ jobs: name: managed-pr-contract-${{ github.run_id }}-${{ github.run_attempt }}-${{ matrix.agent }} path: ${{ runner.temp }}/managed-pr-contract/contract.json if-no-files-found: error + retention-days: 1 pr-managed-activation: name: PR exact all-agent managed runtime activation @@ -456,6 +461,7 @@ jobs: name: managed-image-activation-${{ github.run_id }}-${{ github.run_attempt }} path: e2e-artifacts/live/managed-image-activation/ if-no-files-found: error + retention-days: 1 build-and-validate: name: Build and validate ${{ matrix.display_name }} managed image (${{ matrix.arch }}) diff --git a/.github/workflows/pr-merge-conflict-fixer.yaml b/.github/workflows/pr-merge-conflict-fixer.yaml index 7125b91255a..0de8f7bca3d 100644 --- a/.github/workflows/pr-merge-conflict-fixer.yaml +++ b/.github/workflows/pr-merge-conflict-fixer.yaml @@ -15,6 +15,7 @@ jobs: scan: name: Scan conflicting PRs runs-on: ubuntu-24.04 + timeout-minutes: 10 permissions: contents: read pull-requests: read @@ -111,6 +112,8 @@ jobs: with: name: pr-conflict-resolution-${{ matrix.item.pr_number }}-${{ matrix.item.head_sha }}-${{ matrix.item.base_sha }} path: ${{ env.ARTIFACT_DIR }}/resolution.patch + if-no-files-found: error + retention-days: 1 publish: name: Publish PR #${{ matrix.item.pr_number }} @@ -119,6 +122,7 @@ jobs: - resolve if: ${{ always() && needs.scan.outputs.count != '0' }} runs-on: ubuntu-24.04 + timeout-minutes: 10 permissions: contents: write pull-requests: read diff --git a/.github/workflows/pr-review-advisor.yaml b/.github/workflows/pr-review-advisor.yaml index 289259eeca1..46958f34782 100644 --- a/.github/workflows/pr-review-advisor.yaml +++ b/.github/workflows/pr-review-advisor.yaml @@ -298,6 +298,7 @@ jobs: name: ${{ matrix.advisor.artifact_name }} path: artifacts/${{ matrix.advisor.artifact_dir }}/ if-no-files-found: warn + retention-days: 14 - name: Verify advisor analysis outcome if: always() diff --git a/.github/workflows/pr-self-hosted.yaml b/.github/workflows/pr-self-hosted.yaml index db0dc7bf9e2..9f832911268 100644 --- a/.github/workflows/pr-self-hosted.yaml +++ b/.github/workflows/pr-self-hosted.yaml @@ -30,6 +30,7 @@ concurrency: jobs: get-pr-info: runs-on: ubuntu-latest + timeout-minutes: 5 outputs: pr-info: ${{ steps.get-pr-info.outputs.pr-info }} steps: @@ -39,6 +40,7 @@ jobs: select-llama-cpp-generic-gpu: needs: get-pr-info runs-on: ubuntu-latest + timeout-minutes: 5 outputs: selected: ${{ steps.changed.outputs.selected }} steps: @@ -145,6 +147,8 @@ jobs: steps: - name: Checkout uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false - name: Resolve sandbox base image uses: ./.github/actions/resolve-sandbox-base-image @@ -172,6 +176,7 @@ jobs: name: sandbox-test-image path: /tmp/sandbox-test-image.tar.gz retention-days: 1 + if-no-files-found: error - name: Upload isolation image uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 @@ -179,6 +184,7 @@ jobs: name: isolation-image path: /tmp/isolation-image.tar.gz retention-days: 1 + if-no-files-found: error build-sandbox-images-arm64: runs-on: linux-arm64-cpu4 @@ -186,6 +192,8 @@ jobs: steps: - name: Checkout uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false - name: Resolve sandbox base image uses: ./.github/actions/resolve-sandbox-base-image @@ -209,6 +217,8 @@ jobs: steps: - name: Checkout uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false - name: Download image artifact uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 @@ -229,6 +239,8 @@ jobs: steps: - name: Checkout uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false - name: Set up Node uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 @@ -266,6 +278,8 @@ jobs: steps: - name: Checkout uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false - name: Download image artifact uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 @@ -286,6 +300,8 @@ jobs: steps: - name: Checkout uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false - name: Download image artifact uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 diff --git a/.github/workflows/pr.yaml b/.github/workflows/pr.yaml index 1c7b93097a8..740fddd49d2 100644 --- a/.github/workflows/pr.yaml +++ b/.github/workflows/pr.yaml @@ -115,6 +115,7 @@ jobs: .github/actions/ci-cli-coverage-merge .github/actions/ci-plugin-coverage .github/actions/ci-installer-integration + .github/actions/ci-install-dependencies.sh sparse-checkout-cone-mode: false - name: Run static checks @@ -144,6 +145,7 @@ jobs: .github/actions/ci-cli-coverage-merge .github/actions/ci-plugin-coverage .github/actions/ci-installer-integration + .github/actions/ci-install-dependencies.sh sparse-checkout-cone-mode: false - name: Run build and type checks @@ -168,6 +170,7 @@ jobs: persist-credentials: false sparse-checkout: | .github/actions/ci-installer-integration + .github/actions/ci-install-dependencies.sh sparse-checkout-cone-mode: false - name: Detect trusted installer integration action @@ -229,6 +232,7 @@ jobs: persist-credentials: false sparse-checkout: | .github/actions/ci-wechat-runtime-audit + .github/actions/ci-reviewed-npm-audit/verify-and-install-npm.sh sparse-checkout-cone-mode: false - name: Detect trusted WeChat runtime audit @@ -402,6 +406,7 @@ jobs: .github/actions/ci-cli-coverage-merge .github/actions/ci-plugin-coverage .github/actions/ci-installer-integration + .github/actions/ci-install-dependencies.sh sparse-checkout-cone-mode: false - name: Detect trusted E2E support sharding @@ -572,6 +577,7 @@ jobs: .github/actions/ci-cli-coverage-merge .github/actions/ci-plugin-coverage .github/actions/ci-installer-integration + .github/actions/ci-install-dependencies.sh sparse-checkout-cone-mode: false - name: Merge CLI coverage @@ -607,6 +613,7 @@ jobs: .github/actions/ci-cli-coverage-merge .github/actions/ci-plugin-coverage .github/actions/ci-installer-integration + .github/actions/ci-install-dependencies.sh sparse-checkout-cone-mode: false - name: Run plugin coverage diff --git a/.github/workflows/regression-e2e.yaml b/.github/workflows/regression-e2e.yaml deleted file mode 100644 index b7ddcc68922..00000000000 --- a/.github/workflows/regression-e2e.yaml +++ /dev/null @@ -1,262 +0,0 @@ -# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. -# SPDX-License-Identifier: Apache-2.0 - -name: E2E / Regression Runner - -# Regression E2E holding pen. -# -# Jobs here are intentionally NOT part of the scheduled E2E workflow. They are -# failing-test-first coverage guards or high-signal regressions that should be -# easy to dispatch while the owning fix is in flight. Periodically review this -# workflow and promote stable/high-value jobs into .github/workflows/e2e.yaml. - -on: - workflow_dispatch: - inputs: - pr_number: - description: "PR number (optional; creates a check run on that PR)" - required: false - type: string - default: "" - jobs: - description: >- - Comma-separated regression job names to run (empty = all). - Valid: model-router-provider-routed-inference-e2e,openclaw-plugin-runtime-exdev-e2e,whatsapp-qr-compact-e2e - required: false - type: string - default: "" - -permissions: - actions: read - contents: read - checks: write - pull-requests: write - -concurrency: - group: regression-e2e-${{ github.event_name }}-${{ github.ref }}-${{ inputs.jobs || 'all' }}-${{ inputs.pr_number || github.run_id }} - cancel-in-progress: true - -jobs: - select_regression_jobs: - runs-on: ubuntu-latest - outputs: - model_router_provider_routed_inference: ${{ steps.select.outputs.model_router_provider_routed_inference }} - openclaw_plugin_runtime_exdev: ${{ steps.select.outputs.openclaw_plugin_runtime_exdev }} - whatsapp_qr_compact: ${{ steps.select.outputs.whatsapp_qr_compact }} - steps: - - id: select - env: - JOBS: ${{ inputs.jobs }} - run: | - set -euo pipefail - normalized="$(printf '%s' "$JOBS" | tr -d '[:space:]')" - - includes_job() { - case ",${normalized}," in - *",$1,"*) return 0 ;; - *) return 1 ;; - esac - } - - if [ -z "$normalized" ] || includes_job "model-router-provider-routed-inference-e2e"; then - echo "model_router_provider_routed_inference=true" >> "$GITHUB_OUTPUT" - else - echo "model_router_provider_routed_inference=false" >> "$GITHUB_OUTPUT" - fi - - if [ -z "$normalized" ] || includes_job "openclaw-plugin-runtime-exdev-e2e"; then - echo "openclaw_plugin_runtime_exdev=true" >> "$GITHUB_OUTPUT" - else - echo "openclaw_plugin_runtime_exdev=false" >> "$GITHUB_OUTPUT" - fi - - if [ -z "$normalized" ] || includes_job "whatsapp-qr-compact-e2e"; then - echo "whatsapp_qr_compact=true" >> "$GITHUB_OUTPUT" - else - echo "whatsapp_qr_compact=false" >> "$GITHUB_OUTPUT" - fi - - # ── Model Router provider-routed inference E2E ───────────────── - # Coverage guard for #3255. Model Router onboard must generate a routed - # provider that can answer through inference.local instead of returning - # HTTP 503 / "inference service unavailable" after a successful onboard. - model-router-provider-routed-inference-e2e: - needs: select_regression_jobs - if: >- - github.repository == 'NVIDIA/NemoClaw' && - needs.select_regression_jobs.outputs.model_router_provider_routed_inference == 'true' - runs-on: ubuntu-latest - timeout-minutes: 45 - permissions: - contents: read - steps: - - name: Checkout - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - with: - persist-credentials: false - - - name: Prepare E2E workspace - uses: NVIDIA/NemoClaw/.github/actions/prepare-e2e@f6304bc25fc35bfaa441c8c2fbfee38f72805a75 - - - name: Run Model Router provider-routed inference E2E test - env: - NVIDIA_API_KEY: ${{ secrets.NVIDIA_API_KEY }} - NEMOCLAW_NON_INTERACTIVE: "1" - NEMOCLAW_ACCEPT_THIRD_PARTY_SOFTWARE: "1" - NEMOCLAW_RUN_LIVE_E2E: "1" - run: npx vitest run --project e2e-live test/e2e/live/model-router-provider-routed-inference.test.ts --silent=false --reporter=default - - - name: Upload Model Router provider-routed inference logs on failure - if: failure() - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 - with: - name: model-router-provider-routed-inference-logs - path: | - /tmp/nemoclaw-e2e-model-router-onboard.log - /tmp/nemoclaw-e2e-model-router-health.log - /tmp/nemoclaw-e2e-model-router-response.log - if-no-files-found: ignore - - # ── OpenClaw release-baseline custom-plugin E2E ──────────────── - # The exact v0.0.71 baseline runs in parallel with the current lifecycle - # contract so release provenance does not extend the EXDEV critical path. - openclaw-plugin-runtime-exdev-release-e2e: - needs: select_regression_jobs - if: >- - github.repository == 'NVIDIA/NemoClaw' && - needs.select_regression_jobs.outputs.openclaw_plugin_runtime_exdev == 'true' - runs-on: ubuntu-latest - permissions: - contents: read - timeout-minutes: 55 - steps: - - name: Checkout - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - with: - persist-credentials: false - - - name: Setup Node - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 - with: - node-version: "22" - cache: npm - - - name: Install root dependencies - run: npm ci --ignore-scripts - - - name: Build CLI - run: npm run build:cli - - - name: Run OpenClaw custom-plugin release baseline Vitest test - env: - E2E_ARTIFACT_DIR: ${{ github.workspace }}/e2e-artifacts/live/openclaw-plugin-runtime-exdev-release - E2E_TARGET_ID: openclaw-plugin-runtime-exdev-release - NEMOCLAW_RUN_LIVE_E2E: "1" - NEMOCLAW_SANDBOX_NAME: e2e-oc-exdev-rel - run: | - set -euo pipefail - npx vitest run --project e2e-live \ - test/e2e/live/openclaw-plugin-runtime-exdev.test.ts \ - -t release-baseline \ - --silent=false --reporter=default - - - name: Upload OpenClaw plugin release baseline artifacts - if: always() - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 - with: - name: openclaw-plugin-runtime-exdev-release-artifacts - path: e2e-artifacts/live/openclaw-plugin-runtime-exdev-release/ - include-hidden-files: false - if-no-files-found: ignore - retention-days: 14 - - # ── OpenClaw current lifecycle and runtime-deps EXDEV E2E ───── - # Coverage guard for #6108 / #3513 / #3127. On Ubuntu/OpenShell sandbox - # layouts where /tmp and /sandbox can live on different filesystems, the - # runtime dependency replacement must complete without EXDEV failures. - openclaw-plugin-runtime-exdev-e2e: - needs: select_regression_jobs - if: >- - github.repository == 'NVIDIA/NemoClaw' && - needs.select_regression_jobs.outputs.openclaw_plugin_runtime_exdev == 'true' - runs-on: ubuntu-latest - permissions: - contents: read - # Two bounded 25-minute onboards plus the 20-minute rebuild and 15-minute - # Vitest buffer need 85 minutes; allow 20 more for setup and teardown. - timeout-minutes: 105 - steps: - - name: Checkout - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - with: - persist-credentials: false - - - name: Setup Node - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 - with: - node-version: "22" - cache: npm - - - name: Install root dependencies - run: npm ci --ignore-scripts - - - name: Build CLI - run: npm run build:cli - - - name: Run OpenClaw custom-plugin lifecycle and runtime-deps EXDEV Vitest test - env: - E2E_ARTIFACT_DIR: ${{ github.workspace }}/e2e-artifacts/live/openclaw-plugin-runtime-exdev - E2E_TARGET_ID: openclaw-plugin-runtime-exdev - NEMOCLAW_RUN_LIVE_E2E: "1" - run: | - set -euo pipefail - npx vitest run --project e2e-live \ - test/e2e/live/openclaw-plugin-runtime-exdev.test.ts \ - -t current-lifecycle \ - --silent=false --reporter=default - - - name: Upload OpenClaw plugin runtime-deps EXDEV artifacts - if: always() - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 - with: - name: openclaw-plugin-runtime-exdev-artifacts - path: e2e-artifacts/live/openclaw-plugin-runtime-exdev/ - include-hidden-files: false - if-no-files-found: ignore - retention-days: 14 - - # ── WhatsApp compact-QR reporter-workflow E2E ────────────────── - # Coverage guard for #4522. Drives the real @openclaw/whatsapp + - # openclaw renderQrTerminal path (the symbol the in-sandbox - # `openclaw channels login --channel whatsapp` onQr callback invokes) - # at the version bundled in Dockerfile.base, and asserts the pairing QR - # renders compact with the NemoClaw preload and oversized without it. - # Hermetic: only needs node + npm (no Docker, GPU, or NVIDIA_INFERENCE_API_KEY). - whatsapp-qr-compact-e2e: - needs: select_regression_jobs - if: >- - github.repository == 'NVIDIA/NemoClaw' && - needs.select_regression_jobs.outputs.whatsapp_qr_compact == 'true' - runs-on: ubuntu-latest - timeout-minutes: 15 - steps: - - name: Checkout - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - with: - persist-credentials: false - - - name: Setup Node - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 - with: - node-version: "22" - - - name: Install root dependencies - run: npm ci --ignore-scripts - - - name: Run WhatsApp compact-QR reporter-workflow Vitest test - env: - NEMOCLAW_RUN_LIVE_E2E: "1" - run: | - npx vitest run --project e2e-live \ - test/e2e/live/whatsapp-qr-compact.test.ts \ - --silent=false --reporter=default diff --git a/.github/workflows/release-latest-tag.yaml b/.github/workflows/release-latest-tag.yaml index b7d81a16e9f..3e955a1d905 100644 --- a/.github/workflows/release-latest-tag.yaml +++ b/.github/workflows/release-latest-tag.yaml @@ -28,6 +28,7 @@ concurrency: jobs: update-latest: runs-on: ubuntu-latest + timeout-minutes: 10 steps: - name: Check out repository uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 diff --git a/.github/workflows/sandbox-images-and-e2e.yaml b/.github/workflows/sandbox-images-and-e2e.yaml index f9beb01b48a..8df0d7d3648 100644 --- a/.github/workflows/sandbox-images-and-e2e.yaml +++ b/.github/workflows/sandbox-images-and-e2e.yaml @@ -114,6 +114,7 @@ jobs: name: sandbox-test-image path: /tmp/sandbox-test-image.tar.gz retention-days: 1 + if-no-files-found: error - name: Upload isolation image uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 @@ -121,6 +122,7 @@ jobs: name: isolation-image path: /tmp/isolation-image.tar.gz retention-days: 1 + if-no-files-found: error - name: Clean up Docker auth if: always() @@ -237,6 +239,7 @@ jobs: name: hermes-isolation-image path: /tmp/hermes-isolation-image.tar.gz retention-days: 1 + if-no-files-found: error - name: Record resources after Hermes image build if: always() diff --git a/.github/workflows/wsl-e2e.yaml b/.github/workflows/wsl-e2e.yaml index 0a697d8c375..0a55683284d 100644 --- a/.github/workflows/wsl-e2e.yaml +++ b/.github/workflows/wsl-e2e.yaml @@ -174,3 +174,4 @@ jobs: path: | C:\Users\runneradmin\AppData\Local\Temp\nemoclaw-e2e-install.log if-no-files-found: ignore + retention-days: 14 diff --git a/agents/openclaw/dependency-review.md b/agents/openclaw/dependency-review.md index 364099cda06..1b73ddd4021 100644 --- a/agents/openclaw/dependency-review.md +++ b/agents/openclaw/dependency-review.md @@ -36,7 +36,9 @@ The reviewed audit wrapper reports lower-severity production findings and blocks Because PR #6739's base SHA predates the action, only that PR can use the pinned bootstrap action from signed immutable commit `HOYALIM/NemoClaw@0d2256d71d5bbba3bcaaaa4d01714fa56f22d1e2`. Other PRs fail closed if their base lacks the action. The `main.yaml` workflow uses the merged action. - The action uses Node `22.19.0` and npm `10.9.4`. + The action uses Node.js `22.19.0`. + It downloads `npm@10.9.4` and verifies the archive against the committed Subresource Integrity (SRI) value. + It installs the verified archive in npm offline mode with lifecycle scripts disabled. It materializes the committed graph with scripts disabled. The action rejects any low-or-higher production advisory and verifies registry signatures. The PR and main workflows upload the resulting reports. @@ -50,7 +52,7 @@ The reviewed audit wrapper reports lower-severity production findings and blocks Removal condition: delete the PR #6739 bootstrap checkout, its paired conditional audit step, and the bootstrap-specific test assertions in the first follow-up after this PR merges, before the next release tag; all later PRs must use the normal base-SHA action path. - Advisory command: `npm ci --ignore-scripts --omit=dev --legacy-peer-deps --prefix agents/openclaw/wechat-runtime && npm audit --omit=dev --audit-level=low --json --prefix agents/openclaw/wechat-runtime && npm audit signatures --prefix agents/openclaw/wechat-runtime`. - Advisory review: `2026-07-12`; result: `0` known vulnerabilities across the resolved production graph. -- Regression tests: `test/wechat-locked-install.test.ts` keeps the manifest runtime-lock paths and installer verification dispatch synchronized; `test/verify-wechat-runtime-lock.test.ts` proves the installed graph and OpenClaw peer-range compatibility fail closed; `test/wechat-runtime-audit-workflow.test.ts` keeps the Docker cache lifecycle, base-trusted required CI gate, evidence upload, audit threshold, bounded download-only signature retry, invalid-signature denial, and real npm-pack boundary synchronized. +- Regression tests: `test/wechat-locked-install.test.ts` keeps the manifest runtime-lock paths and installer verification dispatch synchronized; `test/verify-wechat-runtime-lock.test.ts` proves the installed graph and OpenClaw peer-range compatibility fail closed; `test/wechat-runtime-audit-workflow.test.ts` keeps the Docker cache lifecycle, audit threshold, bounded download-only signature retry, invalid-signature denial, and real npm-pack boundary synchronized. The dedicated graph intentionally omits the plugin's `openclaw` peer dependency. The image already installs and integrity-verifies the reviewed OpenClaw runtime separately; auto-installing another OpenClaw copy would create a second unreviewed runtime graph. Disabling scripts also prevents transitive packages from executing lifecycle code during the trusted image build. diff --git a/ci/source-shape-test-budget.json b/ci/source-shape-test-budget.json index c4ba8e1c8ef..29b62dceb32 100644 --- a/ci/source-shape-test-budget.json +++ b/ci/source-shape-test-budget.json @@ -41,41 +41,11 @@ "test": "matches the bundled local-inference host-gateway ports (#5744)", "category": "compatibility" }, - { - "file": "test/candidate-compat.test.ts", - "test": "keeps the manual controller read-only and runs digest-bound deterministic and live lanes (#6691)", - "category": "security" - }, - { - "file": "test/ci-cli-coverage-pi-tools-workflow.test.ts", - "test": "installs pinned fd and ripgrep before CLI coverage can invoke Pi", - "category": "security" - }, - { - "file": "test/cloudflared-update-check-workflow.test.ts", - "test": "keeps automatic and on-demand update checks reachable and credential-free", - "category": "security" - }, { "file": "test/code-change-considerations.test.ts", "test": "keeps the stage-neutral questions in one concise owner", "category": "compatibility" }, - { - "file": "test/code-scanning-workflow.test.ts", - "test": "groups CodeQL action updates so Dependabot keeps the shared revision synchronized", - "category": "security" - }, - { - "file": "test/code-scanning-workflow.test.ts", - "test": "keeps every CodeQL action on one immutable revision", - "category": "security" - }, - { - "file": "test/code-scanning-workflow.test.ts", - "test": "runs only the trusted converter and keeps scanner status separate from conversion failures (#6959)", - "category": "security" - }, { "file": "test/corporate-ca-build-tls-anchor.test.ts", "test": "declares exactly one corporate CA build arg so onboard patching stays unambiguous", @@ -106,21 +76,11 @@ "test": "uses the corporate CA conditionally for all Hermes registry remediations", "category": "security" }, - { - "file": "test/dcode-base-image-workflow.test.ts", - "test": "accepts every discovered publisher and rejects supply-chain mutations", - "category": "security" - }, { "file": "test/e2e-fixture-dependency-review.test.ts", "test": "keeps installed fixture dependencies on exact versions", "category": "security" }, - { - "file": "test/e2e-release-gate-workflow.test.ts", - "test": "replaces legacy target_ref dispatches with the validated checkout contract", - "category": "security" - }, { "file": "test/e2e/live/hermes-e2e.test.ts", "test": "hermes-e2e: install.sh onboards Hermes and proves health plus live inference", @@ -226,11 +186,6 @@ "test": "builds the policy boundary before semantic collection and CLI compilation", "category": "compatibility" }, - { - "file": "test/e2e/support/e2e-workflow.test.ts", - "test": "derives test selectors from code and workflow jobs from workflow metadata", - "category": "compatibility" - }, { "file": "test/e2e/support/e2e-workflow.test.ts", "test": "rejects channels stop/start workflow-boundary drift for secret and artifact handling", @@ -326,21 +281,6 @@ "test": "keeps fork-safe labeling inside the trusted metadata boundary", "category": "security" }, - { - "file": "test/macos-e2e-workflow-boundary.test.ts", - "test": "keeps secret-bearing live E2E on trusted main-branch code", - "category": "security" - }, - { - "file": "test/macos-e2e-workflow-boundary.test.ts", - "test": "pins the macOS artifact publisher to an immutable action", - "category": "security" - }, - { - "file": "test/macos-e2e-workflow-boundary.test.ts", - "test": "keeps gateway lifecycle coverage on supported Apple Silicon macOS", - "category": "compatibility" - }, { "file": "test/messaging-image-env-contract.test.ts", "test": "%s keeps the full plan in build processes but not final runtime environments (#5896)", @@ -406,21 +346,11 @@ "test": "copies the legacy OpenClaw remediation helper before the base build invokes it", "category": "security" }, - { - "file": "test/openclaw-dependency-review.test.ts", - "test": "runs and gates the real patched-distribution harness only from trusted main code", - "category": "security" - }, { "file": "test/openclaw-lifecycle-policy.test.ts", "test": "cross-checks the allowlist against every production archive install boundary", "category": "security" }, - { - "file": "test/openclaw-locked-install.test.ts", - "test": "audits the same lock and rebuilds the base when its graph changes", - "category": "security" - }, { "file": "test/openclaw-locked-install.test.ts", "test": "fails closed on missing required packages and symlinked package roots", @@ -451,16 +381,6 @@ "test": "rejects symlinked package manifests", "category": "security" }, - { - "file": "test/reviewed-npm-audit-workflow.test.ts", - "test": "executes the trusted driver and helper against explicit target inputs", - "category": "security" - }, - { - "file": "test/reviewed-npm-audit-workflow.test.ts", - "test": "runs PR audits from trusted code and keeps the main audit on the checked-in action", - "category": "security" - }, { "file": "test/platform-vitest-main-workflow.test.ts", "test": "pins and verifies the Node.js archive in the trusted WSL helper", @@ -561,121 +481,11 @@ "test": "packages the dormant managed-bootstrap native boundary for every agent image", "category": "security" }, - { - "file": "test/pr-limit-policy.test.ts", - "test": "keeps contributor guidance aligned with the enforced maintainer exemption", - "category": "compatibility" - }, - { - "file": "test/pr-workflow-contract.test.ts", - "test": "does not persist checkout credentials in PR or main jobs", - "category": "security" - }, - { - "file": "test/pr-workflow-contract.test.ts", - "test": "does not run npm lifecycle scripts during CI dependency installs", - "category": "security" - }, - { - "file": "test/pr-workflow-contract.test.ts", - "test": "folds hermetic E2E support and Ollama proxy coverage into existing Vitest lanes", - "category": "security" - }, - { - "file": "test/pr-workflow-contract.test.ts", - "test": "keeps the installer verifier inside the trusted composite action", - "category": "security" - }, - { - "file": "test/openshell-e2e-qualification-workflow.test.ts", - "test": "keeps installer verification independent from full E2E qualification", - "category": "security" - }, - { - "file": "test/pr-workflow-contract.test.ts", - "test": "keeps the trusted test-size guard closed around budget policy changes", - "category": "security" - }, - { - "file": "test/pr-workflow-contract.test.ts", - "test": "pins downloaded CI tooling to reviewed integrity", - "category": "security" - }, - { - "file": "test/pr-workflow-contract.test.ts", - "test": "publishes coverage only from same-repository code (#6692)", - "category": "security" - }, - { - "file": "test/pr-workflow-contract.test.ts", - "test": "records the Dependabot DCO bypass as a successful required job", - "category": "security" - }, - { - "file": "test/pr-workflow-contract.test.ts", - "test": "reruns installer hash verification after a pull request base retarget", - "category": "security" - }, - { - "file": "test/pr-workflow-contract.test.ts", - "test": "reuses the same shared CI actions in PR and main workflows", - "category": "security" - }, - { - "file": "test/pr-workflow-contract.test.ts", - "test": "routes only code-changing PRs through the code-check path", - "category": "compatibility" - }, - { - "file": "test/pr-workflow-contract.test.ts", - "test": "runs pull request installer verification from immutable trusted code", - "category": "security" - }, - { - "file": "test/pr-workflow-contract.test.ts", - "test": "preserves repository validation file routing, command scopes, and compatibility aliases", - "category": "compatibility" - }, - { - "file": "test/pr-workflow-contract.test.ts", - "test": "runs the source-shape guard for root and co-located tests", - "category": "compatibility" - }, - { - "file": "test/pr-workflow-contract.test.ts", - "test": "scopes pre-push typechecks to project and transitive inputs", - "category": "compatibility" - }, - { - "file": "test/regression-e2e-workflow.test.ts", - "test": "prepares every discovered non-hermetic Vitest job before execution (#6692)", - "category": "security" - }, - { - "file": "test/regression-e2e-workflow.test.ts", - "test": "runs the OpenClaw custom-plugin lifecycle and EXDEV guard in a secret-free lane", - "category": "security" - }, - { - "file": "test/regression-e2e-workflow.test.ts", - "test": "runs WhatsApp compact QR through Vitest instead of the retired shell script", - "category": "compatibility" - }, - { - "file": "test/regression-e2e-workflow.test.ts", - "test": "stages the public NVIDIA key for the Model Router's NVIDIA credential", - "category": "security" - }, { "file": "test/release-latest-tag-workflow.test.ts", "test": "binds latest promotion to the exact GitHub-verified tag object", "category": "security" }, - { - "file": "test/release-lkg-brev-image.test.ts", - "test": "keeps LKG dispatch inside the trusted secret boundary (#6772)", - "category": "security" - }, { "file": "test/repro-4538-raw-doctor-perms.test.ts", "test": "emitted openclaw() guard restores the contract AND preserves a nonzero exit", @@ -755,36 +565,6 @@ "file": "test/growth-guardrails-workflow-boundary.test.ts", "test": "flags %s", "category": "security" - }, - { - "file": "test/wechat-runtime-audit-workflow.test.ts", - "test": "makes the trusted audit required in PR and main workflows", - "category": "security" - }, - { - "file": "test/e2e-main-retry-workflow.test.ts", - "test": "subscribes only to completed E2E workflow runs", - "category": "security" - }, - { - "file": "test/e2e-main-retry-workflow.test.ts", - "test": "accepts only trusted main push attempts one through three", - "category": "security" - }, - { - "file": "test/e2e-main-retry-workflow.test.ts", - "test": "uses one source-run concurrency identity and least privileges", - "category": "security" - }, - { - "file": "test/e2e-main-retry-workflow.test.ts", - "test": "checks out trusted controller code and invokes the bounded helper", - "category": "security" - }, - { - "file": "test/e2e-main-retry-workflow.test.ts", - "test": "runs the bounded evidence-upload step after evaluation failure", - "category": "security" } ] } diff --git a/test/candidate-compat.test.ts b/test/candidate-compat.test.ts index 6536e4fd6a3..eb0a2620590 100644 --- a/test/candidate-compat.test.ts +++ b/test/candidate-compat.test.ts @@ -81,104 +81,6 @@ function receipt(overrides: Partial = {}): CandidateReceipt { } describe("OpenShell candidate compatibility contract", () => { - // source-shape-contract: security -- The controller is read-only and candidate code is isolated from provenance resolution. - it("keeps the manual controller read-only and runs digest-bound deterministic and live lanes (#6691)", () => { - const source = readFileSync(resolve(".github/workflows/candidate-compatibility.yaml"), "utf8"); - const workflow = parseYaml(source) as { - jobs: Record< - string, - { - permissions?: Record; - steps?: Array<{ - env?: Record; - id?: string; - if?: string; - name?: string; - run?: string; - with?: Record; - "working-directory"?: string; - }>; - } - >; - on: { workflow_dispatch: { inputs: Record } }; - permissions: Record; - }; - const evidence = workflow.jobs.evidence; - const liveSteps = workflow.jobs.live?.steps ?? []; - const finalize = evidence?.steps?.find((step) => step.name === "Finalize auditable evidence"); - const enforce = evidence?.steps?.find((step) => step.name === "Enforce aggregate result"); - const uploadLiveEvidence = workflow.jobs.live?.steps?.find( - (step) => step.name === "Upload live evidence", - ); - const artifactSafety = workflow.jobs.live?.steps?.find( - (step) => step.name === "Validate final OpenShell gateway auth contract artifacts", - ); - const recordLiveResult = workflow.jobs.live?.steps?.find( - (step) => step.name === "Record receipt-bound live result", - ); - expect(Object.keys(workflow.on.workflow_dispatch.inputs).sort()).toEqual([ - "candidate", - "component", - "nemoclaw_ref", - ]); - expect(workflow.permissions).toEqual({ contents: "read" }); - expect(Object.keys(workflow.jobs).sort()).toEqual([ - "deterministic", - "evidence", - "live", - "resolve", - ]); - expect(source).toContain("candidate compatibility must be dispatched from main"); - expect(source).toContain("path: controller"); - expect(source).toContain("path: candidate-source"); - expect(source).toContain("RESOLUTION_ID: ${{ needs.resolve.outputs.resolution_id }}"); - expect(source).toContain("verify-invocations"); - expect(source).toContain("openshell-gateway-auth-source-contract.test.ts"); - expect(artifactSafety).toMatchObject({ - env: { - E2E_ARTIFACT_DIR: - "${{ github.workspace }}/candidate-source/e2e-artifacts/live/openshell-gateway-auth-contract", - }, - id: "artifact_safety", - if: "${{ always() }}", - run: 'node --experimental-strip-types --no-warnings controller/tools/e2e/openshell-gateway-auth-artifact-safety.mts "$E2E_ARTIFACT_DIR"', - }); - expect(liveSteps.findIndex((step) => step.id === "live_test")).toBeLessThan( - liveSteps.indexOf(recordLiveResult!), - ); - expect(liveSteps.indexOf(recordLiveResult!)).toBeLessThan(liveSteps.indexOf(artifactSafety!)); - expect(liveSteps.indexOf(artifactSafety!)).toBeLessThan(liveSteps.indexOf(uploadLiveEvidence!)); - expect(uploadLiveEvidence?.with?.path).toBe( - [ - "candidate-results/live-openshell-gateway-auth-contract.json", - "candidate-live-observed.json", - "${{ steps.artifact_safety.outcome == 'success' && steps.artifact_safety.outputs.approved_path || '' }}", - "", - ].join("\n"), - ); - expect(evidence?.permissions).toEqual({ actions: "read", contents: "read" }); - expect(finalize?.id).toBe("finalize"); - expect(finalize?.env).toMatchObject({ - GH_TOKEN: "${{ github.token }}", - RUN_ATTEMPT: "${{ github.run_attempt }}", - RUN_ID: "${{ github.run_id }}", - RUN_URL: - "${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}", - }); - expect(finalize?.run).toContain("actions/runs/$RUN_ID/attempts/$RUN_ATTEMPT/jobs?per_page=100"); - expect(finalize?.run).toContain("Number.isSafeInteger(job.id)"); - expect(finalize?.run).toContain("job.name === expectedJobName"); - expect(finalize?.run).toContain("job.run_id === runId"); - expect(finalize?.run).toContain("job.run_attempt === runAttempt"); - expect(finalize?.run).toContain("matches.length === 1"); - expect(finalize?.run).not.toContain("html_url"); - expect(enforce?.run).toContain("::error title=Candidate installer compatibility failed::See"); - expect(enforce?.run).toContain("::error title=Candidate live compatibility failed::See"); - expect(enforce?.run).not.toContain('test "$DETERMINISTIC_RESULT"'); - expect(enforce?.if).toBe("${{ always() }}"); - expect(source).not.toMatch(/\b(?:git push|gh pr|npm publish|docker push)\b/u); - }); - it("links failed evidence to validated jobs and falls back to the workflow run", () => { const source = readFileSync(resolve(".github/workflows/candidate-compatibility.yaml"), "utf8"); const workflow = parseYaml(source) as { diff --git a/test/ci-cli-coverage-pi-tools-workflow.test.ts b/test/ci-cli-coverage-pi-tools-workflow.test.ts deleted file mode 100644 index 0e44144c0f0..00000000000 --- a/test/ci-cli-coverage-pi-tools-workflow.test.ts +++ /dev/null @@ -1,121 +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 { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; -import { tmpdir } from "node:os"; -import { join } from "node:path"; -import { describe, expect, it } from "vitest"; - -import { - type CompositeAction, - readYaml, - type WorkflowJob, - type WorkflowStep, -} from "./helpers/e2e-workflow-contract"; - -type PullRequestWorkflow = { jobs: Record }; - -const action = readYaml(".github/actions/ci-cli-coverage-shard/action.yaml"); -const pullRequestWorkflow = readYaml(".github/workflows/pr.yaml"); -const mainWorkflow = readYaml(".github/workflows/main.yaml"); - -function requiredStep(steps: WorkflowStep[], name: string): WorkflowStep { - const step = steps.find((candidate) => candidate.name === name); - expect(step, `Missing workflow step: ${name}`).toBeDefined(); - return step as WorkflowStep; -} - -function fakeCommand(directory: string, name: string, source: string): void { - writeFileSync(join(directory, name), source, { mode: 0o755 }); -} - -describe("CLI coverage Pi search-tool provisioning", () => { - // source-shape-contract: security -- Base-trusted and bootstrap paths must share one pinned, verified tool contract before untrusted tests invoke Pi - it("installs pinned fd and ripgrep before CLI coverage can invoke Pi", () => { - const actionSteps = action.runs.steps; - const pullRequestJob = pullRequestWorkflow.jobs["cli-test-shards"]; - const mainJob = mainWorkflow.jobs["cli-test-shards"]; - const jobSteps = pullRequestJob.steps ?? []; - const install = requiredStep(actionSteps, "Install pinned Pi search tools"); - const detect = requiredStep(jobSteps, "Detect trusted E2E support sharding"); - const bootstrap = requiredStep(jobSteps, "Install pinned Pi search tools (bootstrap)"); - - expect(install.env).toEqual({ - FD_FIND_VERSION: "9.0.0-1", - RIPGREP_VERSION: "14.1.0-1", - }); - expect(pullRequestJob["runs-on"]).toBe("ubuntu-24.04"); - expect(mainJob["runs-on"]).toBe("ubuntu-24.04"); - expect(actionSteps.indexOf(install)).toBeLessThan( - actionSteps.indexOf(requiredStep(actionSteps, "Install dependencies")), - ); - expect(actionSteps.indexOf(install)).toBeLessThan( - actionSteps.indexOf(requiredStep(actionSteps, "Run CLI coverage and E2E support shard")), - ); - expect(detect.run).toContain("name: Install pinned Pi search tools"); - expect(detect.run).toContain('echo "pi-search-tools=true" >> "$GITHUB_OUTPUT"'); - expect(detect.run).toContain('echo "pi-search-tools=false" >> "$GITHUB_OUTPUT"'); - expect(bootstrap.if).toBe( - "${{ steps.trusted-shard-capabilities.outputs.pi-search-tools != 'true' }}", - ); - expect(bootstrap.env).toEqual(install.env); - expect(bootstrap.run).toBe(install.run); - expect(jobSteps.indexOf(bootstrap)).toBeLessThan( - jobSteps.indexOf(requiredStep(jobSteps, "Run CLI coverage shard")), - ); - - const temp = mkdtempSync(join(tmpdir(), "nemoclaw-cli-pi-tools-install-")); - const fakeBin = join(temp, "bin"); - const callLog = join(temp, "calls.log"); - mkdirSync(fakeBin); - fakeCommand(fakeBin, "sudo", '#!/bin/bash\nprintf \'sudo %s\\n\' "$*" >> "$CALL_LOG"\n'); - fakeCommand( - fakeBin, - "dpkg-query", - `#!/bin/bash -printf 'dpkg-query %s\\n' "$*" >> "$CALL_LOG" -case "$*" in - *fd-find) printf '%s' "$FD_FIND_VERSION" ;; - *ripgrep) printf '%s' "$RIPGREP_VERSION" ;; - *) exit 1 ;; -esac -`, - ); - fakeCommand( - fakeBin, - "fdfind", - "#!/bin/bash\nprintf 'fdfind %s\\n' \"$*\" >> \"$CALL_LOG\"\nprintf 'fdfind 9.0.0\\n'\n", - ); - fakeCommand( - fakeBin, - "rg", - "#!/bin/bash\nprintf 'rg %s\\n' \"$*\" >> \"$CALL_LOG\"\nprintf 'ripgrep 14.1.0\\n-SIMD -AVX\\n'\n", - ); - - try { - const result = spawnSync("bash", ["-c", install.run ?? ""], { - encoding: "utf8", - env: { - ...process.env, - ...install.env, - CALL_LOG: callLog, - PATH: `${fakeBin}:${process.env.PATH ?? ""}`, - }, - timeout: 5_000, - }); - expect(result.status, String(result.stderr)).toBe(0); - const calls = readFileSync(callLog, "utf8"); - expect(calls).toContain("sudo apt-get update -qq"); - expect(calls).toContain( - "sudo apt-get install -y --no-install-recommends fd-find=9.0.0-1 ripgrep=14.1.0-1", - ); - expect(calls).toContain("dpkg-query -W -f=${Version} fd-find"); - expect(calls).toContain("dpkg-query -W -f=${Version} ripgrep"); - expect(calls).toContain("fdfind --version"); - expect(calls).toContain("rg --version"); - } finally { - rmSync(temp, { force: true, recursive: true }); - } - }); -}); diff --git a/test/ci-install-dependencies.test.ts b/test/ci-install-dependencies.test.ts new file mode 100644 index 00000000000..7c831dbc1d6 --- /dev/null +++ b/test/ci-install-dependencies.test.ts @@ -0,0 +1,40 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { spawnSync } from "node:child_process"; +import { chmodSync, mkdtempSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +import { afterEach, describe, expect, it } from "vitest"; + +const temporaryRoots: string[] = []; + +afterEach(() => { + for (const root of temporaryRoots.splice(0)) rmSync(root, { force: true, recursive: true }); +}); + +describe("shared CI dependency installer", () => { + it("installs root and plugin dependencies from lockfiles without lifecycle scripts", () => { + const root = mkdtempSync(join(tmpdir(), "nemoclaw-ci-install-")); + temporaryRoots.push(root); + const bin = join(root, "bin"); + const trace = join(root, "npm.trace"); + mkdirSync(bin); + const npm = join(bin, "npm"); + writeFileSync(npm, `#!/bin/sh\nprintf '%s\n' "$*" >> "$NPM_TRACE"\n`); + chmodSync(npm, 0o755); + + const result = spawnSync("bash", [".github/actions/ci-install-dependencies.sh"], { + cwd: join(import.meta.dirname, ".."), + encoding: "utf8", + env: { ...process.env, NPM_TRACE: trace, PATH: `${bin}:${process.env.PATH || ""}` }, + }); + + expect(result.status, result.stderr).toBe(0); + expect(readFileSync(trace, "utf8").trim().split("\n")).toEqual([ + "ci --ignore-scripts", + "--prefix nemoclaw ci --ignore-scripts", + ]); + }); +}); diff --git a/test/cloudflared-update-check-workflow.test.ts b/test/cloudflared-update-check-workflow.test.ts index ba996ee8729..2b9d1f4bb1d 100644 --- a/test/cloudflared-update-check-workflow.test.ts +++ b/test/cloudflared-update-check-workflow.test.ts @@ -131,24 +131,6 @@ describe("cloudflared update-check workflow contract", () => { workflow.jobs?.["check-cloudflared"]?.steps?.find((step) => typeof step.run === "string") ?.run ?? ""; - // source-shape-contract: security -- Automatic and on-demand checks must preserve the credential-free dependency monitoring boundary - it("keeps automatic and on-demand update checks reachable and credential-free", () => { - expect({ - automatic: - workflow.on?.schedule?.some( - (entry) => typeof entry.cron === "string" && entry.cron.trim() !== "", - ) ?? false, - onDemand: Object.hasOwn(workflow.on ?? {}, "workflow_dispatch"), - }).toEqual({ automatic: true, onDemand: true }); - expect(workflow.permissions).toEqual({ contents: "read" }); - - const job = workflow.jobs?.["check-cloudflared"]; - const checkout = job?.steps?.find((step) => step.uses?.startsWith("actions/checkout@")); - expect(job?.permissions).toBeUndefined(); - expect(checkout?.uses).toMatch(FULL_SHA_ACTION); - expect(checkout?.with?.["persist-credentials"]).toBe(false); - }); - it("extracts exactly five identical reviewed version and SHA256 pins", () => { const versions = pinValues(e2e, "CLOUDFLARED_VERSION"); const hashes = pinValues(e2e, "CLOUDFLARED_DEB_SHA256"); diff --git a/test/code-scanning-workflow.test.ts b/test/code-scanning-workflow.test.ts index 62603a85d7a..a0e37350207 100644 --- a/test/code-scanning-workflow.test.ts +++ b/test/code-scanning-workflow.test.ts @@ -9,30 +9,15 @@ import path from "node:path"; import { describe, expect, it } from "vitest"; import { readYaml, type Workflow } from "./helpers/e2e-workflow-contract"; -type DependabotUpdate = { - "package-ecosystem"?: string; - directory?: string; - groups?: Record; -}; - const workflow = readYaml(".github/workflows/code-scanning.yaml"); -const dependabot = readYaml<{ updates?: DependabotUpdate[] }>(".github/dependabot.yml"); const shellcheckSteps = workflow.jobs.shellcheck?.steps ?? []; -const codeqlActionPrefix = "github/codeql-action/"; - function requiredStep(name: string) { const step = shellcheckSteps.find((candidate) => candidate.name === name); assert(step, `ShellCheck workflow is missing step: ${name}`); return step; } -function stepIndex(name: string) { - const index = shellcheckSteps.findIndex((candidate) => candidate.name === name); - assert(index >= 0, `ShellCheck workflow is missing step: ${name}`); - return index; -} - function writeExecutable(file: string, content: string) { fs.writeFileSync(file, content, { mode: 0o755 }); } @@ -99,132 +84,7 @@ esac return { calls, result }; } -describe("Code scanning workflow dependency updates", () => { - // source-shape-contract: security -- One immutable CodeQL revision prevents partial scanner action upgrades - it("keeps every CodeQL action on one immutable revision", () => { - const codeqlActions = Object.values(workflow.jobs ?? {}) - .flatMap((job) => job.steps ?? []) - .map((step) => step.uses) - .filter((uses): uses is string => uses?.startsWith(codeqlActionPrefix) ?? false); - - expect( - codeqlActions.map((uses) => uses.slice(codeqlActionPrefix.length).split("@")[0]).sort(), - ).toEqual(["analyze", "init", "upload-sarif"]); - - const revisions = codeqlActions.map((uses) => uses.split("@")[1]); - expect(revisions).toHaveLength(3); - for (const revision of revisions) { - expect(revision).toMatch(/^[0-9a-f]{40}$/); - } - expect(new Set(revisions).size).toBe(1); - }); - - // source-shape-contract: security -- Grouped CodeQL updates preserve the reviewed single-revision scanner boundary - it("groups CodeQL action updates so Dependabot keeps the shared revision synchronized", () => { - const githubActionsUpdate = dependabot.updates?.find( - (update) => update["package-ecosystem"] === "github-actions" && update.directory === "/", - ); - const groups = Object.values(githubActionsUpdate?.groups ?? {}); - - expect(groups.some((group) => group.patterns?.includes("github/codeql-action/*"))).toBe(true); - }); -}); - describe("ShellCheck SARIF workflow boundary", () => { - // source-shape-contract: security -- A sparse trusted checkout, disabled credential persistence, an isolated helper environment, and fail-closed ordering protect SARIF publication - it("runs only the trusted converter and keeps scanner status separate from conversion failures (#6959)", () => { - expect(workflow.jobs.shellcheck).toBeDefined(); - - const checkout = requiredStep("Checkout"); - expect(checkout.with?.path).toBe("source"); - expect(checkout.with?.["persist-credentials"]).toBe(false); - - const trustedCheckout = requiredStep("Check out the trusted ShellCheck converter"); - expect(trustedCheckout.uses).toBe("actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1"); - expect(trustedCheckout.with).toMatchObject({ - ref: "${{ github.event_name == 'pull_request' && github.event.pull_request.base.sha || github.workflow_sha }}", - path: "trusted-shellcheck-converter", - "persist-credentials": false, - "sparse-checkout": "scripts/shellcheck-json1-to-sarif.mts\n", - "sparse-checkout-cone-mode": false, - }); - - const detect = requiredStep("Detect trusted ShellCheck converter"); - expect(detect.id).toBe("converter"); - expect(detect.run).toContain( - "trusted-shellcheck-converter/scripts/shellcheck-json1-to-sarif.mts", - ); - expect(detect.run).toContain('echo "present=false" >> "$GITHUB_OUTPUT"'); - expect(detect.run).toContain("conversion and upload begin after this helper lands"); - - const setupNode = requiredStep("Setup Node.js"); - expect(setupNode.if).toBe("steps.converter.outputs.present == 'true'"); - expect(setupNode.uses).toBe("actions/setup-node@820762786026740c76f36085b0efc47a31fe5020"); - expect(setupNode.with?.["node-version"]).toBe("22.19.0"); - - const install = requiredStep("Install ShellCheck"); - expect(install.if).toBe("steps.converter.outputs.present == 'true'"); - expect(install.run).toContain("shellcheck --format=json1"); - expect(install.run).toContain("Acquire::Retries=3"); - expect(install.run).toContain("Acquire::http::Timeout=15"); - expect(install.run).toContain("Acquire::https::Timeout=15"); - - const collect = requiredStep("Collect shell files"); - expect(collect.if).toBe("steps.converter.outputs.present == 'true'"); - expect(collect).toMatchObject({ "working-directory": "source" }); - expect(collect.run).toContain("$GITHUB_WORKSPACE/shell-files.txt"); - expect(collect.run).toContain("git ls-files -z --"); - expect(collect.run).toContain("sort -zu"); - - const generate = requiredStep("Generate ShellCheck SARIF"); - expect(generate.if).toBe( - "steps.converter.outputs.present == 'true' && steps.shell-files.outputs.has_files == 'true'", - ); - expect(generate).toMatchObject({ "working-directory": "source" }); - expect(generate.run).toContain( - '"$GITHUB_WORKSPACE/trusted-shellcheck-converter/scripts/shellcheck-json1-to-sarif.mts"', - ); - expect(generate.run).not.toContain( - '"$GITHUB_WORKSPACE/source/scripts/shellcheck-json1-to-sarif.mts"', - ); - expect(generate.run).toContain("mapfile -d '' -t shell_files"); - expect(generate.run).toContain('shellcheck --format=json1 -- "${shell_files[@]}"'); - expect(generate.run).not.toContain("xargs"); - expect(generate.run).toContain('case "$sc_exit" in'); - expect(generate.run).toContain('exit "$sc_exit"'); - expect(generate.run).toContain("ShellCheck found issues; continuing"); - expect(generate.run).toContain("refusing to convert or upload incomplete results"); - expect(generate.run).toContain("conversion_exit=$?"); - expect(generate.run).toContain('exit "$conversion_exit"'); - expect(generate.run).not.toContain("def level_map"); - expect(generate.run).not.toContain("cat > shellcheck.sarif"); - - const checkRuns = requiredStep("Check SARIF has runs"); - expect(checkRuns.if).toBe( - "steps.converter.outputs.present == 'true' && steps.shell-files.outputs.has_files == 'true'", - ); - expect(checkRuns.run).toContain("jq '.runs | length' shellcheck.sarif"); - - const upload = requiredStep("Upload ShellCheck SARIF"); - expect(upload.if).toBe( - "steps.converter.outputs.present == 'true' && steps.shell-files.outputs.has_files == 'true' && steps.sarif-runs.outputs.has_runs == 'true'", - ); - expect(upload.with?.checkout_path).toBe("source"); - - const orderedSteps = [ - stepIndex("Checkout"), - stepIndex("Check out the trusted ShellCheck converter"), - stepIndex("Detect trusted ShellCheck converter"), - stepIndex("Setup Node.js"), - stepIndex("Install ShellCheck"), - stepIndex("Collect shell files"), - stepIndex("Generate ShellCheck SARIF"), - stepIndex("Check SARIF has runs"), - stepIndex("Upload ShellCheck SARIF"), - ]; - expect(orderedSteps).toEqual([...orderedSteps].sort((left, right) => left - right)); - }); - it("keeps a preinstalled ShellCheck only when its json1 formatter works (#7684)", () => { const { calls, result } = runShellCheckInstall({ preinstalledSupportsJson1: true }); diff --git a/test/dcode-base-image-workflow.test.ts b/test/dcode-base-image-workflow.test.ts index 9a6450f363f..58fabf45c8f 100644 --- a/test/dcode-base-image-workflow.test.ts +++ b/test/dcode-base-image-workflow.test.ts @@ -5,254 +5,13 @@ import fs from "node:fs"; import path from "node:path"; import { describe, expect, it } from "vitest"; -import YAML from "yaml"; - -import { createDeepAgentsCodeBaseImageResolutionOptions } from "../src/lib/agent/deep-agents-code-base-image.ts"; -import { loadAgent } from "../src/lib/agent/defs.ts"; - -type WorkflowStep = { - name?: string; - id?: string; - uses?: string; - run?: string; - env?: Record; - with?: Record; -}; - -type PublisherMatrixEntry = { - agent?: string; - arch?: string; - display_name?: string; - dockerfile?: string; - image?: string; - platform?: string; - runner?: string; -}; - -type WorkflowJob = { - name?: string; - needs?: string | string[]; - "runs-on"?: string; - "timeout-minutes"?: number; - strategy?: { - "fail-fast"?: boolean; - matrix?: { include?: PublisherMatrixEntry[] }; - }; - steps?: WorkflowStep[]; -}; - -type Workflow = { - on?: { push?: { paths?: string[] } }; - jobs?: Record; -}; - -type Publisher = { - jobName: string; - job: WorkflowJob; - build: WorkflowStep; - buildIndex: number; - dockerfile: string; - matrix: PublisherMatrixEntry; -}; - -type RegistryCacheEntry = { - mode?: string; - ref?: string; -}; const repoRoot = path.resolve(import.meta.dirname, ".."); -const workflow = YAML.parse( - fs.readFileSync(path.join(repoRoot, ".github", "workflows", "base-image.yaml"), "utf8"), -) as Workflow; -const FULL_SHA_ACTION = /^[^@]+@[0-9a-f]{40}$/i; -const OPENCLAW_AGENT_GATE = - 'if [ "$AGENT" = "openclaw" ] && [ -n "${OPENCLAW_VERSION_INPUT}" ]; then'; -const PLATFORM_DIGEST_OUTPUT = - "type=image,name=${{ env.REGISTRY }}/${{ matrix.image }},push-by-digest=true,name-canonical=true,push=true"; - -function renderMatrixValue(value: unknown, matrix: PublisherMatrixEntry): string { - return String(value ?? "").replace( - /\$\{\{\s*matrix\.([a-z_]+)\s*\}\}/gu, - (_match, key: keyof PublisherMatrixEntry) => String(matrix[key] ?? ""), - ); -} - -function publisherBuildSteps(candidate: Workflow): Omit[] { - return Object.entries(candidate.jobs ?? {}).flatMap(([jobName, job]) => { - const steps = job.steps ?? []; - return steps - .map((build, buildIndex) => ({ build, buildIndex })) - .filter(({ build }) => build.uses?.startsWith("docker/build-push-action@")) - .map(({ build, buildIndex }) => ({ jobName, job, build, buildIndex })); - }); -} - -function publisherJobs(candidate: Workflow): Publisher[] { - return publisherBuildSteps(candidate).flatMap(({ jobName, job, build, buildIndex }) => - (job.strategy?.matrix?.include ?? []) - .filter((matrix) => matrix.display_name) - .map((matrix) => ({ - jobName: `${jobName} (${matrix.display_name})`, - job, - build, - buildIndex, - dockerfile: renderMatrixValue(build.with?.file, matrix), - matrix, - })), - ); -} - -function openClawPlatformPublishers(candidate: Workflow): Publisher[] { - const jobName = "build-openclaw-platforms"; - const job = candidate.jobs?.[jobName] as WorkflowJob; - const steps = job?.steps ?? []; - const buildIndex = steps.findIndex((step) => step.uses?.startsWith("docker/build-push-action@")); - const build = steps[buildIndex] as WorkflowStep; - return (job.strategy?.matrix?.include ?? []).map((matrix) => ({ - jobName: `${jobName} (${matrix.arch ?? "unnamed"})`, - job, - build, - buildIndex, - dockerfile: renderMatrixValue(build.with?.file, matrix), - matrix, - })); -} - -function copiedInputs(dockerfile: string): string[] { - return [ - ...fs - .readFileSync(path.join(repoRoot, dockerfile), "utf8") - .matchAll(/^COPY\s+(?!--from=)(?:--\S+\s+)*(\S+)\s+\S+/gm), - ].map(([, input]) => input); -} - -function copiedLocks(dockerfile: string): string[] { - return copiedInputs(dockerfile).filter((input) => input.endsWith(".lock")); -} - -function registryCacheEntries(value: unknown): RegistryCacheEntry[] { - return String(value ?? "") - .split(/\r?\n/u) - .map((entry) => entry.trim()) - .filter((entry) => entry.split(",").includes("type=registry")) - .map((entry) => - Object.fromEntries( - entry - .split(",") - .filter((field) => field !== "type=registry") - .map((field) => field.split("=", 2) as [string, string]), - ), - ); -} - -function hasAgentScopedOpenClawVersion(step: WorkflowStep | undefined): boolean { - const segments = (step?.run ?? "").split(OPENCLAW_AGENT_GATE); - return ( - step?.env?.AGENT === "${{ matrix.agent }}" && - segments.length === 3 && - segments[0].includes('openclaw_build_arg=""') && - segments[1].includes('openclaw_build_arg="OPENCLAW_VERSION=${OPENCLAW_VERSION_INPUT}"') && - segments[2].includes('if [[ "$OPENCLAW_VERSION_INPUT"') - ); -} - -function validatePublisherInputs(candidate: Workflow, publishers: Publisher[]): string[] { - const triggerPaths = candidate.on?.push?.paths ?? []; - return publishers.flatMap(({ jobName, dockerfile }) => { - const dockerfileExists = - dockerfile.length > 0 && fs.existsSync(path.join(repoRoot, dockerfile)); - const copiedInputPaths = dockerfileExists ? copiedInputs(dockerfile) : []; - return [ - ...(!dockerfileExists ? [`${jobName} must publish from an existing Dockerfile`] : []), - ...(!triggerPaths.includes(dockerfile) - ? [`${jobName} Dockerfile must trigger the publisher workflow`] - : []), - ...copiedInputPaths - .filter((input) => !triggerPaths.includes(input)) - .map((input) => `${jobName} copied input must trigger the publisher workflow: ${input}`), - ]; - }); -} - -function validatePublishers(candidate: Workflow): string[] { - const publishers = publisherJobs(candidate); - const exportedCacheRefCounts = new Map(); - for (const { build, matrix } of publishers) { - const cacheRef = - registryCacheEntries(renderMatrixValue(build.with?.["cache-to"], matrix))[0]?.ref ?? ""; - exportedCacheRefCounts.set(cacheRef, (exportedCacheRefCounts.get(cacheRef) ?? 0) + 1); - } - - return [ - ...validatePublisherInputs(candidate, publishers), - ...publishers.flatMap(({ jobName, job, build, buildIndex, matrix }) => { - const steps = job.steps ?? []; - const metadata = steps.find((step) => step.id === "meta"); - const guardIndex = steps.findIndex((step) => - (step.run ?? "").includes("scripts/check-production-build-args.sh"), - ); - const guard = steps[guardIndex]; - const dockerActions = steps.filter((step) => step.uses?.startsWith("docker/")); - const tags = String(metadata?.with?.tags ?? ""); - const metadataImage = renderMatrixValue(metadata?.with?.images, matrix); - const expectedCacheRef = `${metadataImage}:buildcache-${matrix.arch}`; - const cacheFrom = registryCacheEntries(renderMatrixValue(build.with?.["cache-from"], matrix)); - const cacheTo = registryCacheEntries(renderMatrixValue(build.with?.["cache-to"], matrix)); - const importedCacheRef = cacheFrom[0]?.ref; - const exportedCacheRef = cacheTo[0]?.ref; - return [ - ...(guardIndex < 0 || guardIndex >= buildIndex - ? [`${jobName} must validate production build args before publishing`] - : []), - ...(!hasAgentScopedOpenClawVersion(guard) - ? [`${jobName} must scope OpenClaw version handling to the OpenClaw matrix entry`] - : []), - ...(!metadata?.uses?.startsWith("docker/metadata-action@") - ? [`${jobName} must derive publication metadata with docker/metadata-action`] - : []), - ...(metadataImage.length === 0 ? [`${jobName} must declare a publication image`] : []), - ...(tags.length > 0 ? [`${jobName} platform build must not publish mutable tags`] : []), - ...dockerActions - .filter((step) => !FULL_SHA_ACTION.test(step.uses ?? "")) - .map((step) => `${jobName} Docker action must use a full commit SHA: ${step.uses}`), - ...(!FULL_SHA_ACTION.test(build.uses ?? "") - ? [`${jobName} build-push action must use a full commit SHA`] - : []), - ...(build.with?.context !== "." ? [`${jobName} must publish from repository context`] : []), - ...(build.with?.platforms !== "${{ matrix.platform }}" - ? [`${jobName} must build its selected native platform`] - : []), - ...(build.with?.outputs !== PLATFORM_DIGEST_OUTPUT - ? [`${jobName} must push an immutable platform digest`] - : []), - ...(build.with?.tags !== undefined || build.with?.push !== undefined - ? [`${jobName} platform build must not publish tags directly`] - : []), - ...(build.with?.labels !== "${{ steps.meta.outputs.labels }}" - ? [`${jobName} must use the reviewed metadata labels`] - : []), - ...(cacheFrom.length !== 1 || !importedCacheRef - ? [`${jobName} cache-from must declare exactly one registry cache ref`] - : []), - ...(cacheTo.length !== 1 || !exportedCacheRef - ? [`${jobName} cache-to must declare exactly one registry cache ref`] - : []), - ...(importedCacheRef !== exportedCacheRef - ? [`${jobName} must import and export the same registry cache ref`] - : []), - ...(cacheTo[0]?.mode !== "max" - ? [`${jobName} must export its registry cache in max mode`] - : []), - ...(exportedCacheRef && exportedCacheRef !== expectedCacheRef - ? [`${jobName} registry cache must use its publication image buildcache tag`] - : []), - ...(exportedCacheRef && exportedCacheRefCounts.get(exportedCacheRef) !== 1 - ? [`${jobName} must use a publisher-unique registry cache ref`] - : []), - ]; - }), - ]; -} +const baseDockerfiles = [ + "Dockerfile.base", + "agents/hermes/Dockerfile.base", + "agents/langchain-deepagents-code/Dockerfile.base", +] as const; function pinnedAptVersion(dockerfile: string, packageName: string): string { const source = fs.readFileSync(path.join(repoRoot, dockerfile), "utf8"); @@ -261,428 +20,14 @@ function pinnedAptVersion(dockerfile: string, packageName: string): string { return version as string; } -describe("base-image publication behavior", () => { - // source-shape-contract: security -- Publisher mutations must preserve immutable actions, guarded arguments, and trusted registry cache ownership - it("accepts every discovered publisher and rejects supply-chain mutations", () => { - const publishers = publisherJobs(workflow); - expect(publisherBuildSteps(workflow)).toHaveLength(3); - expect( - publishers.map(({ dockerfile, matrix }) => ({ - agent: matrix.agent, - arch: matrix.arch, - dockerfile, - image: matrix.image, - })), - ).toEqual([ - { - agent: "hermes", - arch: "amd64", - dockerfile: "agents/hermes/Dockerfile.base", - image: "nvidia/nemoclaw/hermes-sandbox-base", - }, - { - agent: "hermes", - arch: "arm64", - dockerfile: "agents/hermes/Dockerfile.base", - image: "nvidia/nemoclaw/hermes-sandbox-base", - }, - { - agent: "langchain-deepagents-code", - arch: "amd64", - dockerfile: "agents/langchain-deepagents-code/Dockerfile.base", - image: "nvidia/nemoclaw/langchain-deepagents-code-sandbox-base", - }, - { - agent: "langchain-deepagents-code", - arch: "arm64", - dockerfile: "agents/langchain-deepagents-code/Dockerfile.base", - image: "nvidia/nemoclaw/langchain-deepagents-code-sandbox-base", - }, - ]); - for (const publisher of publishers) { - expect(publisher.job.strategy?.["fail-fast"]).toBe(false); - } - expect(validatePublishers(workflow)).toEqual([]); - expect(validatePublisherInputs(workflow, openClawPlatformPublishers(workflow))).toEqual([]); - - const mutated = structuredClone(workflow); - const mutatedPublisher = publisherJobs(mutated)[0]; - const mutatedSteps = mutatedPublisher.job.steps ?? []; - const otherPublisher = publisherJobs(mutated)[1]; - const otherCacheRef = registryCacheEntries( - renderMatrixValue(otherPublisher.build.with?.["cache-to"], otherPublisher.matrix), - )[0]?.ref; - const mutatedGuard = mutatedSteps.find((step) => - (step.run ?? "").includes("scripts/check-production-build-args.sh"), - ); - mutatedPublisher.build.uses = "docker/build-push-action@v7"; - mutatedPublisher.build.with = { - ...mutatedPublisher.build.with, - push: false, - "cache-from": "type=gha", - "cache-to": `type=registry,ref=${otherCacheRef}`, - }; - mutatedGuard!.run = "true"; - - expect(validatePublishers(mutated)).toEqual( - expect.arrayContaining([ - `${mutatedPublisher.jobName} must validate production build args before publishing`, - `${mutatedPublisher.jobName} Docker action must use a full commit SHA: docker/build-push-action@v7`, - `${mutatedPublisher.jobName} build-push action must use a full commit SHA`, - `${mutatedPublisher.jobName} platform build must not publish tags directly`, - `${mutatedPublisher.jobName} cache-from must declare exactly one registry cache ref`, - `${mutatedPublisher.jobName} must import and export the same registry cache ref`, - `${mutatedPublisher.jobName} must export its registry cache in max mode`, - `${mutatedPublisher.jobName} registry cache must use its publication image buildcache tag`, - `${mutatedPublisher.jobName} must use a publisher-unique registry cache ref`, - ]), - ); - - const invertedGate = structuredClone(workflow); - const invertedPublisher = publisherJobs(invertedGate)[0]; - const invertedGuard = (invertedPublisher.job.steps ?? []).find((step) => - (step.run ?? "").includes("scripts/check-production-build-args.sh"), - ); - invertedGuard!.run = invertedGuard!.run!.replaceAll( - OPENCLAW_AGENT_GATE, - OPENCLAW_AGENT_GATE.replace("openclaw", "hermes"), - ); - - expect(validatePublishers(invertedGate)).toContain( - `${invertedPublisher.jobName} must scope OpenClaw version handling to the OpenClaw matrix entry`, - ); - - const missingTriggers = structuredClone(workflow); - const copiedInput = copiedInputs("Dockerfile.base")[0]; - missingTriggers.on!.push!.paths = missingTriggers.on!.push!.paths!.filter( - (triggerPath) => triggerPath !== "Dockerfile.base" && triggerPath !== copiedInput, - ); - expect( - validatePublisherInputs(missingTriggers, openClawPlatformPublishers(missingTriggers)), - ).toEqual( - expect.arrayContaining([ - "build-openclaw-platforms (amd64) Dockerfile must trigger the publisher workflow", - `build-openclaw-platforms (arm64) copied input must trigger the publisher workflow: ${copiedInput}`, - ]), - ); - }); +describe("base-image dependency contracts", () => { + it("keeps shared apt dependencies pinned and aligned across base images (#6679)", () => { + const curlVersions = baseDockerfiles.map((dockerfile) => pinnedAptVersion(dockerfile, "curl")); - it("publishes OpenClaw atomically from native architecture runners", () => { - const publishers = openClawPlatformPublishers(workflow); - const platformJob = workflow.jobs?.["build-openclaw-platforms"]; - const manifestJob = workflow.jobs?.["build-and-push-openclaw"]; - - expect(platformJob?.needs).toEqual(["reviewed-npm-audit"]); - expect(platformJob?.["timeout-minutes"]).toBe(60); - expect(platformJob?.strategy?.["fail-fast"]).toBe(false); - expect( - publishers.map(({ matrix }) => ({ - agent: matrix.agent, - arch: matrix.arch, - platform: matrix.platform, - runner: matrix.runner, - })), - ).toEqual([ - { - agent: "openclaw", - arch: "amd64", - platform: "linux/amd64", - runner: "ubuntu-24.04", - }, - { - agent: "openclaw", - arch: "arm64", - platform: "linux/arm64", - runner: "ubuntu-24.04-arm", - }, - ]); - - for (const { job, build, buildIndex, dockerfile, matrix } of publishers) { - const steps = job.steps ?? []; - const guardIndex = steps.findIndex((step) => - (step.run ?? "").includes("scripts/check-production-build-args.sh"), - ); - const digestExport = steps.find((step) => step.name === "Export platform digest"); - const digestUpload = steps.find((step) => step.name === "Upload platform digest"); - const cacheSuffix = `buildcache-${matrix.arch}`; - - expect(dockerfile).toBe("Dockerfile.base"); - expect(job["runs-on"]).toBe("${{ matrix.runner }}"); - expect(steps.some((step) => step.uses?.startsWith("docker/setup-qemu-action@"))).toBe(false); - expect(guardIndex).toBeGreaterThanOrEqual(0); - expect(guardIndex).toBeLessThan(buildIndex); - expect(hasAgentScopedOpenClawVersion(steps[guardIndex])).toBe(true); - expect(build.with?.platforms).toBe("${{ matrix.platform }}"); - expect(build.with?.outputs).toBe(PLATFORM_DIGEST_OUTPUT); - expect(build.with?.tags).toBeUndefined(); - expect(renderMatrixValue(build.with?.["cache-from"], matrix)).toContain(cacheSuffix); - expect(renderMatrixValue(build.with?.["cache-to"], matrix)).toContain( - `${cacheSuffix},mode=max`, - ); - expect(digestExport?.env?.ARCH).toBe("${{ matrix.arch }}"); - expect(digestExport?.run).toContain("^sha256:[0-9a-f]{64}$"); - expect(digestExport?.run).toContain('touch "$RUNNER_TEMP/digests/${ARCH}-${DIGEST#sha256:}"'); - expect(digestUpload?.with?.name).toBe( - "openclaw-base-digest-${{ github.run_id }}-${{ github.run_attempt }}-${{ matrix.arch }}", - ); - for (const step of steps.filter((step) => step.uses)) { - expect(step.uses, `${matrix.arch}: ${step.name}`).toMatch(FULL_SHA_ACTION); - } - } - - expect(manifestJob?.name).toBe("Build and push OpenClaw base image"); - expect(manifestJob?.needs).toEqual(["build-openclaw-platforms", "reviewed-npm-audit"]); - expect(manifestJob?.["timeout-minutes"]).toBe(10); - expect( - manifestJob?.steps?.some((step) => step.uses?.startsWith("docker/build-push-action@")), - ).toBe(false); - const download = manifestJob?.steps?.find((step) => step.name === "Download platform digests"); - const metadata = manifestJob?.steps?.find((step) => step.id === "meta"); - const createManifest = manifestJob?.steps?.find( - (step) => step.name === "Create and verify multi-platform manifest", - ); - expect(download?.with).toMatchObject({ - pattern: "openclaw-base-digest-${{ github.run_id }}-${{ github.run_attempt }}-*", - "merge-multiple": true, - }); - expect(metadata?.with?.images).toBe("${{ env.REGISTRY }}/nvidia/nemoclaw/sandbox-base"); - expect(metadata?.with?.tags).toContain("type=raw,value=latest"); - expect(metadata?.with?.tags).toContain("type=ref,event=tag"); - expect(metadata?.with?.tags).toContain("type=sha,prefix=,format=short"); - expect(createManifest?.env?.AGENT).toBe("openclaw"); - const openClawManifestScript = createManifest?.run ?? ""; - expect(openClawManifestScript).toContain('"${#digest_files[@]}" -ne 2'); - expect(openClawManifestScript).toContain("^(amd64|arm64)-([0-9a-f]{64})$"); - expect(openClawManifestScript).toContain("--format '{{.Image.OS}}/{{.Image.Architecture}}'"); - expect(openClawManifestScript).toContain( - 'if [ "$source_platform" != "linux/$expected_arch" ]; then', - ); - expect(openClawManifestScript).toContain("duplicate platform digest"); - expect(openClawManifestScript).toContain("declare -A source_digests=()"); - expect(openClawManifestScript).toContain( - 'source_digests["linux/$expected_arch"]="sha256:$digest"', - ); - expect(openClawManifestScript.indexOf("source_platform=")).toBeLessThan( - openClawManifestScript.indexOf("docker buildx imagetools create"), - ); - expect(openClawManifestScript).toContain('--tag "$candidate_tag"'); - expect(openClawManifestScript).toContain('--metadata-file "$candidate_metadata"'); - expect(openClawManifestScript).toContain('"${sources[@]}"'); - expect(openClawManifestScript).toContain('"amd64,arm64"'); - expect(openClawManifestScript).toContain('"${source_digests[linux/amd64]}"'); - expect(openClawManifestScript).toContain('"${source_digests[linux/arm64]}"'); - expect(openClawManifestScript).toContain("platform_digests_json="); - expect(openClawManifestScript).toContain("declare -A platform_digests=()"); - expect(openClawManifestScript).toContain("scripts/export-managed-base-image-contract.sh"); - expect(openClawManifestScript).not.toContain("first_tag="); - expect(openClawManifestScript).not.toContain( - 'imagetools create "${tag_args[@]}" "${sources[@]}"', - ); - const openClawValidationIndex = openClawManifestScript.indexOf( - "scripts/checks/validate-managed-base-index.sh", - ); - const openClawPromotionIndex = openClawManifestScript.indexOf("publication_metadata="); - expect(openClawManifestScript.indexOf("candidate_tag=")).toBeLessThan(openClawValidationIndex); - expect(openClawValidationIndex).toBeLessThan(openClawPromotionIndex); - expect(openClawManifestScript.slice(openClawPromotionIndex)).toContain('"${tag_args[@]}"'); - expect(openClawManifestScript.slice(openClawPromotionIndex)).toContain('"$reference"'); - expect(openClawManifestScript).toContain("published_digest="); - expect(openClawManifestScript).toContain('if [ "$published_digest" != "$digest" ]; then'); - for (const step of (manifestJob?.steps ?? []).filter((step) => step.uses)) { - expect(step.uses, step.name).toMatch(FULL_SHA_ACTION); - } - }); - - it("publishes sibling images atomically from native architecture runners", () => { - const publishers = publisherJobs(workflow); - const imagePublishers = [ - { - agent: "hermes", - platformJobName: "build-hermes-platforms", - manifestJobName: "build-and-push-hermes", - manifestName: "Build and push Hermes base image", - artifactPattern: "hermes-base-digest-${{ github.run_id }}-${{ github.run_attempt }}-*", - image: "${{ env.REGISTRY }}/nvidia/nemoclaw/hermes-sandbox-base", - }, - { - agent: "langchain-deepagents-code", - platformJobName: "build-dcode-platforms", - manifestJobName: "build-and-push-dcode", - manifestName: "Build and push Deep Agents Code base image", - artifactPattern: - "langchain-deepagents-code-base-digest-${{ github.run_id }}-${{ github.run_attempt }}-*", - image: "${{ env.REGISTRY }}/nvidia/nemoclaw/langchain-deepagents-code-sandbox-base", - }, - ]; - - for (const { platformJobName } of imagePublishers) { - const platformJob = workflow.jobs?.[platformJobName]; - expect(platformJob?.needs).toEqual(["reviewed-npm-audit"]); - expect(platformJob?.["timeout-minutes"]).toBe(60); - expect(platformJob?.["runs-on"]).toBe("${{ matrix.runner }}"); - expect(platformJob?.strategy?.["fail-fast"]).toBe(false); - } - expect( - publishers.map(({ matrix }) => ({ - agent: matrix.agent, - arch: matrix.arch, - platform: matrix.platform, - runner: matrix.runner, - })), - ).toEqual([ - { - agent: "hermes", - arch: "amd64", - platform: "linux/amd64", - runner: "ubuntu-24.04", - }, - { - agent: "hermes", - arch: "arm64", - platform: "linux/arm64", - runner: "ubuntu-24.04-arm", - }, - { - agent: "langchain-deepagents-code", - arch: "amd64", - platform: "linux/amd64", - runner: "ubuntu-24.04", - }, - { - agent: "langchain-deepagents-code", - arch: "arm64", - platform: "linux/arm64", - runner: "ubuntu-24.04-arm", - }, - ]); - - for (const { job, build, matrix } of publishers) { - const steps = job.steps ?? []; - const digestExport = steps.find((step) => step.name === "Export platform digest"); - const digestUpload = steps.find((step) => step.name === "Upload platform digest"); - - expect(steps.some((step) => step.uses?.startsWith("docker/setup-qemu-action@"))).toBe(false); - expect(build.with?.platforms).toBe("${{ matrix.platform }}"); - expect(build.with?.outputs).toBe(PLATFORM_DIGEST_OUTPUT); - expect(digestExport?.env?.ARCH).toBe("${{ matrix.arch }}"); - expect(digestExport?.run).toContain('touch "$RUNNER_TEMP/digests/${ARCH}-${DIGEST#sha256:}"'); - expect(digestUpload?.with?.name).toBe( - "${{ matrix.agent }}-base-digest-${{ github.run_id }}-${{ github.run_attempt }}-${{ matrix.arch }}", - ); - expect(renderMatrixValue(digestUpload?.with?.name, matrix)).toBe( - `${matrix.agent}-base-digest-` + - "${{ github.run_id }}-${{ github.run_attempt }}-" + - matrix.arch, - ); - } - - for (const imagePublisher of imagePublishers) { - const manifestJob = workflow.jobs?.[imagePublisher.manifestJobName]; - expect(manifestJob?.name).toBe(imagePublisher.manifestName); - expect(manifestJob?.needs).toEqual([imagePublisher.platformJobName, "reviewed-npm-audit"]); - expect(manifestJob?.["timeout-minutes"]).toBe(10); - expect( - manifestJob?.steps?.some((step) => step.uses?.startsWith("docker/build-push-action@")), - ).toBe(false); - const download = manifestJob?.steps?.find( - (step) => step.name === "Download platform digests", - ); - const metadata = manifestJob?.steps?.find((step) => step.id === "meta"); - const createManifest = manifestJob?.steps?.find( - (step) => step.name === "Create and verify multi-platform manifest", - ); - expect(download?.with).toMatchObject({ - pattern: imagePublisher.artifactPattern, - "merge-multiple": true, - }); - expect(metadata?.with?.images).toBe(imagePublisher.image); - expect(metadata?.with?.tags).toContain("type=raw,value=latest"); - expect(metadata?.with?.tags).toContain("type=ref,event=tag"); - expect(metadata?.with?.tags).toContain("type=sha,prefix=,format=short"); - expect(createManifest?.env?.AGENT).toBe(imagePublisher.agent); - expect(createManifest?.env?.IMAGE).toBe(imagePublisher.image); - const manifestScript = createManifest?.run ?? ""; - expect(manifestScript).toContain('"${#digest_files[@]}" -ne 2'); - expect(manifestScript).toContain("^(amd64|arm64)-([0-9a-f]{64})$"); - expect(manifestScript).toContain("--format '{{.Image.OS}}/{{.Image.Architecture}}'"); - expect(manifestScript).toContain( - 'scripts/checks/retry-docker-imagetools-inspect.sh "$source"', - ); - expect(manifestScript).toContain( - 'scripts/checks/retry-docker-imagetools-inspect.sh "$reference" --raw', - ); - expect(manifestScript).not.toContain("docker buildx imagetools inspect"); - expect(manifestScript).toContain('if [ "$source_platform" != "linux/$expected_arch" ]; then'); - expect(manifestScript).toContain("duplicate platform digest"); - expect(manifestScript).toContain("declare -A source_digests=()"); - expect(manifestScript).toContain('source_digests["linux/$expected_arch"]="sha256:$digest"'); - expect(manifestScript.indexOf("source_platform=")).toBeLessThan( - manifestScript.indexOf("docker buildx imagetools create"), - ); - expect(manifestScript).toContain('--tag "$candidate_tag"'); - expect(manifestScript).toContain('--metadata-file "$candidate_metadata"'); - expect(manifestScript).toContain('"${sources[@]}"'); - expect(manifestScript).toContain('"amd64,arm64"'); - expect(manifestScript).toContain("scripts/checks/validate-managed-base-index.sh"); - expect(manifestScript).toContain('"${source_digests[linux/amd64]}"'); - expect(manifestScript).toContain('"${source_digests[linux/arm64]}"'); - expect(manifestScript).toContain("platform_digests_json="); - expect(manifestScript).toContain("declare -A platform_digests=()"); - expect(manifestScript).toContain("scripts/export-managed-base-image-contract.sh"); - expect(manifestScript).not.toContain("first_tag="); - expect(manifestScript).not.toContain('imagetools create "${tag_args[@]}" "${sources[@]}"'); - const validationIndex = manifestScript.indexOf( - "scripts/checks/validate-managed-base-index.sh", - ); - const promotionIndex = manifestScript.indexOf("publication_metadata="); - expect(manifestScript.indexOf("candidate_tag=")).toBeLessThan(validationIndex); - expect(validationIndex).toBeLessThan(promotionIndex); - expect(manifestScript.slice(promotionIndex)).toContain('"${tag_args[@]}"'); - expect(manifestScript.slice(promotionIndex)).toContain('"$reference"'); - expect(manifestScript).toContain("published_digest="); - expect(manifestScript).toContain('if [ "$published_digest" != "$digest" ]; then'); - for (const step of (manifestJob?.steps ?? []).filter((step) => step.uses)) { - expect(step.uses, step.name).toMatch(FULL_SHA_ACTION); - } - } - }); - - it("keeps shared apt dependencies pinned and aligned across discovered base images (#6679)", () => { - const dockerfiles = [ - ...openClawPlatformPublishers(workflow).map(({ dockerfile }) => dockerfile), - ...publisherJobs(workflow).map(({ dockerfile }) => dockerfile), - ].filter((dockerfile, index, all) => all.indexOf(dockerfile) === index); - const curlVersions = dockerfiles.map((dockerfile) => pinnedAptVersion(dockerfile, "curl")); - - expect(new Set(dockerfiles).size).toBe(dockerfiles.length); expect(new Set(curlVersions).size).toBe(1); - for (const dockerfile of dockerfiles) { + for (const dockerfile of baseDockerfiles) { const source = fs.readFileSync(path.join(repoRoot, dockerfile), "utf8"); expect(source, dockerfile).toMatch(/^FROM\s+\S+@sha256:[0-9a-f]{64}\s*$/m); } }); - - it("binds a copied Deep Agents Code hash lock to the adjacent runtime manifest", () => { - const lockedPublisher = publisherJobs(workflow).find( - ({ dockerfile }) => copiedLocks(dockerfile).length > 0, - ); - expect(lockedPublisher).toBeDefined(); - const [lockPath] = copiedLocks(lockedPublisher!.dockerfile); - const lock = fs.readFileSync(path.join(repoRoot, lockPath), "utf8"); - const dockerfilePath = path.join(repoRoot, lockedPublisher!.dockerfile); - const agent = loadAgent(path.basename(path.dirname(lockedPublisher!.dockerfile))); - const resolution = createDeepAgentsCodeBaseImageResolutionOptions(agent, dockerfilePath); - const lockedVersion = lock.match(/^deepagents-code==([^\s\\]+)/m)?.[1]; - - expect(resolution).toBeDefined(); - expect(resolution?.inputPaths).toEqual( - expect.arrayContaining([agent.manifestPath, path.join(repoRoot, lockPath)]), - ); - expect(lock).toMatch(/^deepagents-code==[^\s\\]+\s+\\\n\s+--hash=sha256:[0-9a-f]{64}/m); - expect(lockedVersion).toBeDefined(); - expect(agent.expectedVersion).toBe(lockedVersion); - expect(resolution?.validationDescription).toBe( - `deepagents-code==${lockedVersion} and the immutable security package inventory`, - ); - }); }); diff --git a/test/e2e-main-retry-workflow.test.ts b/test/e2e-main-retry-workflow.test.ts deleted file mode 100644 index 059d1f2da48..00000000000 --- a/test/e2e-main-retry-workflow.test.ts +++ /dev/null @@ -1,110 +0,0 @@ -// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. -// SPDX-License-Identifier: Apache-2.0 - -import fs from "node:fs"; -import YAML from "yaml"; -import { describe, expect, it } from "vitest"; - -type Step = { - name?: string; - uses?: string; - if?: string; - env?: Record; - run?: string; - with?: Record; -}; - -type Workflow = { - name: string; - on: { workflow_run: { workflows: string[]; types: string[] } }; - permissions: Record; - jobs: { - evaluate: { - if: string; - concurrency: Record; - permissions: Record; - steps: Step[]; - }; - }; -}; - -function workflow(): Workflow { - return YAML.parse(fs.readFileSync(".github/workflows/e2e-main-retry.yaml", "utf8")) as Workflow; -} - -function step(name: string): Step { - return workflow().jobs.evaluate.steps.find((candidate) => candidate.name === name)!; -} - -describe("main E2E retry workflow", () => { - // source-shape-contract: security -- The write-capable retry controller must subscribe only to the reviewed E2E workflow completion event - it("subscribes only to completed E2E workflow runs", () => { - const value = workflow(); - expect(value.name).toBe("E2E / Main Retry"); - expect(value.on).toEqual({ workflow_run: { workflows: ["E2E"], types: ["completed"] } }); - expect(value.permissions).toEqual({}); - }); - - // source-shape-contract: security -- Source identity and attempt guards prevent fork, PR, manual, and out-of-range retry writes - it("accepts only trusted main push attempts one through three", () => { - const guard = workflow().jobs.evaluate.if; - for (const fragment of [ - "github.run_attempt == 1", - "github.repository == 'NVIDIA/NemoClaw'", - "github.event.workflow_run.status == 'completed'", - "github.event.workflow_run.event == 'push'", - "github.event.workflow_run.path == '.github/workflows/e2e.yaml'", - "github.event.workflow_run.display_title == 'E2E main'", - "github.event.workflow_run.head_branch == 'main'", - "github.event.workflow_run.head_repository.full_name == 'NVIDIA/NemoClaw'", - "github.event.workflow_run.run_attempt >= 1", - "github.event.workflow_run.run_attempt <= 3", - ]) { - expect(guard).toContain(fragment); - } - expect(guard).not.toContain("pull_request"); - expect(guard).not.toContain("workflow_dispatch"); - expect(guard).not.toContain("||"); - expect(guard.match(/&&/gu)).toHaveLength(9); - }); - - // source-shape-contract: security -- Source-run serialization and least privilege prevent concurrent or broader GitHub mutations - it("uses one source-run concurrency identity and least privileges", () => { - const job = workflow().jobs.evaluate; - expect(job.concurrency).toEqual({ - group: "e2e-main-retry-${{ github.event.workflow_run.id }}", - "cancel-in-progress": false, - }); - expect(job.permissions).toEqual({ actions: "write", contents: "read" }); - }); - - // source-shape-contract: security -- Trusted default-branch controller code must run before any write-capable GitHub request - it("checks out trusted controller code and invokes the bounded helper", () => { - expect(step("Checkout trusted retry controller")).toMatchObject({ - uses: "actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1", - with: { ref: "${{ github.workflow_sha }}", "persist-credentials": false }, - }); - expect(step("Evaluate main E2E retry")).toMatchObject({ - env: { - GITHUB_TOKEN: "${{ github.token }}", - RETRY_EVIDENCE_PATH: "${{ runner.temp }}/e2e-main-retry-evidence.json", - SOURCE_RUN_ID: "${{ github.event.workflow_run.id }}", - }, - }); - expect(step("Evaluate main E2E retry").run).toContain("tools/e2e/main-run-retry.mts"); - }); - - // source-shape-contract: security -- The bounded upload step must run after evaluation failure without widening artifact paths - it("runs the bounded evidence-upload step after evaluation failure", () => { - expect(step("Upload retry evidence")).toMatchObject({ - if: "${{ always() }}", - uses: "actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a", - with: { - name: "e2e-main-retry-${{ github.event.workflow_run.id }}-${{ github.event.workflow_run.run_attempt }}", - path: "${{ runner.temp }}/e2e-main-retry-evidence.json", - "if-no-files-found": "warn", - "retention-days": 14, - }, - }); - }); -}); diff --git a/test/e2e-release-gate-workflow.test.ts b/test/e2e-release-gate-workflow.test.ts index 33ffe5a2230..f2c00dd1718 100644 --- a/test/e2e-release-gate-workflow.test.ts +++ b/test/e2e-release-gate-workflow.test.ts @@ -18,20 +18,6 @@ type E2eWorkflow = { const e2eWorkflow = readYaml(".github/workflows/e2e.yaml"); describe("release gate workflow resource contracts", () => { - // source-shape-contract: security -- Trusted checkout selection binds TUI evidence to the validated controller commit - it("replaces legacy target_ref dispatches with the validated checkout contract", () => { - const inputs = e2eWorkflow.on?.workflow_dispatch?.inputs; - const tuiJob = e2eWorkflow.jobs["openclaw-tui-chat-correlation"]; - const checkout = tuiJob.steps?.find((step) => step.uses?.startsWith("actions/checkout@")); - - expect(inputs).toHaveProperty("checkout_sha"); - expect(inputs).not.toHaveProperty("target_ref"); - expect(tuiJob.permissions).toEqual({ contents: "read" }); - expect(checkout?.with?.ref).toBe("${{ inputs.checkout_sha || github.sha }}"); - expect(tuiJob.env?.NEMOCLAW_TUI_EXPECTED_CHECKOUT_SHA).toBe( - "${{ inputs.checkout_sha || github.sha }}", - ); - }); it("rejects trusted dispatch receipt contract drift", () => { const workflow = structuredClone(e2eWorkflow); const steps = workflow.jobs["generate-matrix"].steps!; diff --git a/test/e2e/README.md b/test/e2e/README.md index e318bd9bdc8..d140eafdd30 100644 --- a/test/e2e/README.md +++ b/test/e2e/README.md @@ -19,9 +19,10 @@ before those targets run; local runners must provide it themselves. attempts, requests at most two failed-job reruns, and uploads attempt evidence. - The `staging-brev-launchable` job in `.github/workflows/e2e.yaml` validates the baked candidate without installing or copying NemoClaw source. -- Platform workflows such as macOS, WSL, sandbox image, and regression E2E - call their target E2E tests directly. The Ollama auth proxy target is - selected through `.github/workflows/e2e.yaml`. +- `.github/workflows/macos-e2e.yaml`, `.github/workflows/wsl-e2e.yaml`, and + `.github/workflows/sandbox-images-and-e2e.yaml` call focused E2E targets directly. + `.github/workflows/e2e.yaml` selects free-standing jobs, including + `whatsapp-qr-compact` and `ollama-auth-proxy`. ## CI execution shape @@ -30,10 +31,10 @@ before those targets run; local runners must provide it themselves. The candidate CLI comes from the source commit that an E2E run tests. The `generate-matrix` job builds it once. The job publishes root `dist/` and `nemoclaw/dist/shared/` in one content-addressed artifact. -The workflow has 62 artifact-using job definitions. -Each selected job execution restores the artifact instead of running `npm run build:cli`. -Each selected job still runs the pinned preparation action to install Node.js and project dependencies. -It sets `build-cli: "false"` so the preparation action does not rebuild the CLI. +The boundary validator derives artifact consumers from jobs that use the pinned preparation action. +It excludes `generate-matrix` and the no-build and trusted-build jobs in `E2E_JOB_POLICY`. +Each selected consumer restores the artifact instead of running `npm run build:cli`. +Each consumer runs the pinned preparation action with `build-cli: "false"` to install Node.js and project dependencies. The `managed-image-protected-runtime` qualification does not use this artifact. It builds the CLI from the trusted workflow checkout and never executes or restores the candidate CLI. diff --git a/test/e2e/docs/README.md b/test/e2e/docs/README.md index 2a35a568f28..3c573288b7f 100644 --- a/test/e2e/docs/README.md +++ b/test/e2e/docs/README.md @@ -341,10 +341,10 @@ test/e2e/ Each macOS shard installs the pinned OpenShell formula and has a 30-minute budget. Each WSL shard has a 90-minute budget, and WSL runs its additional root-required contracts on shard 1 only. - `macos-e2e.yaml`, `wsl-e2e.yaml`, and `regression-e2e.yaml` call focused E2E - targets directly for their platform coverage. - Repository-hosted targets, including `ollama-auth-proxy`, are selected - through `.github/workflows/e2e.yaml`. + `.github/workflows/macos-e2e.yaml`, `.github/workflows/wsl-e2e.yaml`, and + `.github/workflows/sandbox-images-and-e2e.yaml` call focused E2E targets + directly. `.github/workflows/e2e.yaml` selects free-standing jobs, including + `whatsapp-qr-compact` and `ollama-auth-proxy`. - The `staging-brev-launchable` job validates the exact baked candidate in preinstalled mode. Generic Brev VMs with source overlays are not a qualification boundary. diff --git a/test/e2e/support/cli-artifact-workflow-boundary.test.ts b/test/e2e/support/cli-artifact-workflow-boundary.test.ts index 82d4836e27c..749941ed759 100644 --- a/test/e2e/support/cli-artifact-workflow-boundary.test.ts +++ b/test/e2e/support/cli-artifact-workflow-boundary.test.ts @@ -938,4 +938,13 @@ describe("exact-commit CLI artifact workflow boundary", () => { "security-posture must verify and restore the exact CLI artifact exactly once", ); }); + + it("rejects an added CLI artifact consumer outside the reviewed workflow contract", () => { + const workflow = workflowFixture(); + workflow.jobs["added-consumer"] = structuredClone(workflow.jobs["cloud-inference"]); + + expect(validateCliArtifactWorkflowBoundary(workflow)).toContain( + "CLI artifact workflow settings, consumer job settings, and steps up to and including CLI artifact restore must match the required contract", + ); + }); }); diff --git a/test/e2e/support/e2e-workflow.test.ts b/test/e2e/support/e2e-workflow.test.ts index ebc8bb1416e..9775e6a96be 100644 --- a/test/e2e/support/e2e-workflow.test.ts +++ b/test/e2e/support/e2e-workflow.test.ts @@ -138,6 +138,17 @@ describe("e2e workflow boundary", () => { ).toEqual({ runLaunchableE2e: false }); }); + it("rejects a WhatsApp compact QR job that omits the jobs and targets dispatch conditions", () => { + const workflow = readWorkflow() as { + jobs: Record; + }; + workflow.jobs["whatsapp-qr-compact"]!.if = "${{ github.event_name != 'workflow_dispatch' }}"; + + expect(validateE2eWorkflow(workflow)).toContain( + "whatsapp-qr-compact job must use the shared jobs selector condition", + ); + }); + it("rejects a full dispatch with changed input, correlation, or selector contracts (#7487)", () => { const workflow = readWorkflow() as { "run-name": string; @@ -707,117 +718,6 @@ describe("e2e workflow boundary", () => { }, ); - // source-shape-contract: compatibility -- Cross-checks generated selectors against the executable workflow job registry - it("derives test selectors from code and workflow jobs from workflow metadata", { - timeout: 60_000, - }, () => { - const inventory = readFreeStandingJobsInventory(); - const workflow = readWorkflow() as { - jobs: Record; if?: string }>; - }; - const workflowJobs = new Set(Object.keys(workflow.jobs)); - const portableWorkflowSource = fs.readFileSync( - path.join(process.cwd(), ".github", "workflows", "portable-profile-e2e.yaml"), - "utf8", - ); - const fullE2eSource = fs.readFileSync( - path.join(process.cwd(), "test", "e2e", "live", "full-e2e.test.ts"), - "utf8", - ); - const portableWorkflow = YAML.parse(portableWorkflowSource) as { - on?: { pull_request?: { paths?: string[] }; push?: { paths?: string[] } }; - }; - const portableProofInputs = [ - "scripts/install-openshell.sh", - "src/lib/sandbox/**", - "test/e2e/live/full-e2e.test.ts", - "test/e2e/live/launch-agent-turn.ts", - "test/e2e/live/portable-profile-gateway-proof.ts", - "test/e2e/live/portable-profile-rootless-linux.test.ts", - "tools/e2e/check-semantic-phases.mts", - ]; - - expect(validateFreeStandingWorkflowInventory()).toEqual([]); - expect(portableWorkflow.on?.push?.paths).toEqual(expect.arrayContaining(portableProofInputs)); - expect(portableWorkflow.on?.push?.paths).toEqual(expect.arrayContaining(portableProofInputs)); - expect(portableWorkflowSource).toContain("github.ref == 'refs/heads/main'"); - expect(portableWorkflowSource).toContain("NEMOCLAW_EXPERIMENTAL_PROFILE: portable"); - expect(portableWorkflowSource).toContain( - "NVIDIA_INFERENCE_API_KEY: ${{ secrets.NVIDIA_INFERENCE_API_KEY }}", - ); - expect(fullE2eSource.match(/id: FULL_E2E_TARGET_ID,/gu)).toHaveLength(2); - expect(inventory.allowedJobs).not.toHaveLength(0); - expect(inventory.targetToJob.size).toBeGreaterThan(0); - expect(inventory.workflowJobs.every((job) => workflowJobs.has(job))).toBe(true); - expect([...inventory.targetToJob.values()].every((job) => workflowJobs.has(job))).toBe(true); - expect(inventory.liveTestToJobs.get("test/e2e/live/token-rotation.test.ts")).toEqual([ - "token-rotation", - ]); - expect(inventory.liveTestToJobs.get("test/e2e/live/full-e2e.test.ts")).toEqual( - expect.arrayContaining(["full-e2e", "security-posture"]), - ); - expect(workflow.jobs["gpu-e2e"]?.env?.NEMOCLAW_MODEL).toBe("qwen3.5:9b"); - expect(workflow.jobs["gpu-double-onboard"]?.env?.NEMOCLAW_MODEL).toBe("qwen3.5:9b"); - const driftedWorkflow = structuredClone(workflow); - const compatibilityJob = driftedWorkflow.jobs["retired-selector-compatibility"] ?? {}; - compatibilityJob.if = compatibilityJob.if?.replace( - ",docs-validation,", - ",future-retired-selector,", - ); - expect(validateE2eWorkflow(driftedWorkflow)).toContain( - "retired-selector-compatibility job selector gate must match retired selector contract", - ); - expect( - focusedE2eJobsForChangedFiles( - [ - "test/e2e/live/token-rotation.test.ts", - "docs/get-started/quickstart.mdx", - "test/e2e/live/token-rotation.test.ts", - ], - inventory, - ), - ).toEqual([ - { - id: "token-rotation", - matchedFiles: ["test/e2e/live/token-rotation.test.ts"], - }, - ]); - expect( - focusedE2eJobsForChangedFiles( - ["test/e2e/live/openclaw-plugin-runtime-exdev-lifecycle.ts"], - inventory, - ), - ).toEqual([ - { - id: "openclaw-plugin-runtime-exdev", - matchedFiles: ["test/e2e/live/openclaw-plugin-runtime-exdev-lifecycle.ts"], - }, - ]); - expect( - focusedE2eJobsForChangedFiles(["test/e2e/live/rebuild-hermes-cron-restore.ts"], inventory), - ).toEqual([ - { - id: "rebuild-hermes", - matchedFiles: ["test/e2e/live/rebuild-hermes-cron-restore.ts"], - }, - { - id: "rebuild-hermes-stale-base", - matchedFiles: ["test/e2e/live/rebuild-hermes-cron-restore.ts"], - }, - ]); - expect( - focusedE2eJobsForChangedFiles( - ["test/e2e/live/openshell-gateway-upgrade-helpers.ts"], - inventory, - ), - ).toEqual([ - { - id: "openshell-gateway-upgrade", - matchedFiles: ["test/e2e/live/openshell-gateway-upgrade-helpers.ts"], - }, - ]); - }); - it("rejects malformed free-standing workflow metadata before matrix generation", { timeout: 60_000, }, () => { diff --git a/test/e2e/support/sandbox-name-workflow-boundary.test.ts b/test/e2e/support/sandbox-name-workflow-boundary.test.ts index d1a85ceb9a9..051ee9cc018 100644 --- a/test/e2e/support/sandbox-name-workflow-boundary.test.ts +++ b/test/e2e/support/sandbox-name-workflow-boundary.test.ts @@ -9,10 +9,7 @@ import { } from "../../../tools/e2e/sandbox-name-workflow-boundary.mts"; import { readYaml, type Workflow } from "../../helpers/e2e-workflow-contract"; -const WORKFLOW_PATHS = [ - ".github/workflows/e2e.yaml", - ".github/workflows/regression-e2e.yaml", -] as const; +const WORKFLOW_PATHS = [".github/workflows/e2e.yaml"] as const; describe("live workflow sandbox name boundary", () => { it.each( diff --git a/test/macos-e2e-workflow-boundary.test.ts b/test/macos-e2e-workflow-boundary.test.ts deleted file mode 100644 index a362d3c67f2..00000000000 --- a/test/macos-e2e-workflow-boundary.test.ts +++ /dev/null @@ -1,93 +0,0 @@ -// 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 WorkflowStep = { - name?: string; - if?: string; - env?: Record; - run?: string; - uses?: string; - with?: Record; -}; - -type WorkflowJob = { - if?: string; - permissions?: Record; - "runs-on"?: string; - "timeout-minutes"?: number; - steps?: WorkflowStep[]; -}; - -type Workflow = { - jobs?: Record; - on?: Record; -}; - -function readMacosWorkflow(): Workflow { - return YAML.parse( - fs.readFileSync(path.join(process.cwd(), ".github", "workflows", "macos-e2e.yaml"), "utf8"), - ) as Workflow; -} - -function jobNamed(name: string): WorkflowJob { - const job = readMacosWorkflow().jobs?.[name]; - expect(job).toBeDefined(); - return job!; -} - -function stepNamed(name: string, jobName = "macos-e2e"): WorkflowStep { - const step = jobNamed(jobName).steps?.find((candidate) => candidate.name === name); - expect(step).toBeDefined(); - return step!; -} - -describe("macOS E2E workflow boundary", () => { - // source-shape-contract: security -- Live credentials must stay gated to trusted main-branch workflow code - it("keeps secret-bearing live E2E on trusted main-branch code", () => { - expect(readMacosWorkflow().on?.pull_request).toBeUndefined(); - - expect(stepNamed("Run macOS full E2E").if).toContain("github.ref == 'refs/heads/main'"); - - expect(String(stepNamed("Run macOS full E2E").env?.NVIDIA_INFERENCE_API_KEY)).toContain( - "github.ref == 'refs/heads/main'", - ); - }); - - // source-shape-contract: compatibility -- OpenShell publishes macOS gateway assets only for Apple Silicon - it("keeps gateway lifecycle coverage on supported Apple Silicon macOS", () => { - const workflow = readMacosWorkflow(); - const lifecycle = stepNamed("Run gateway lifecycle regressions"); - - expect(jobNamed("macos-e2e")["runs-on"]).toBe("macos-26"); - expect(JSON.stringify(workflow.jobs ?? {})).not.toContain("macos-15-intel"); - expect(lifecycle.run).toContain("test/tunnel-gateway-port-release-runtime.test.ts"); - expect(lifecycle.run).toContain("test/onboard-gateway-prelaunch-cutover.test.ts"); - expect(lifecycle.run).toContain("test/onboard-gateway-legacy-identity-upgrade-runtime.test.ts"); - }); - - // source-shape-contract: security -- The failure-only macOS artifact publisher must retain diagnostic paths and an immutable action - it("pins the macOS artifact publisher to an immutable action", () => { - const workflow = readMacosWorkflow(); - const upload = workflow.jobs?.["macos-e2e"]?.steps?.find( - (step) => step.name === "Upload logs on failure", - ); - - expect(upload?.uses).toBe("actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a"); - const paths = String(upload?.with?.path) - .split(/\r?\n/) - .map((entry) => entry.trim()) - .filter(Boolean); - expect(paths).toHaveLength(2); - expect(paths).toEqual([ - "/tmp/nemoclaw-e2e-*.log", - "${{ github.workspace }}/e2e-artifacts/live", - ]); - expect(upload?.if).toBe("failure()"); - }); -}); diff --git a/test/managed-image-publication-workflow.test.ts b/test/managed-image-publication-workflow.test.ts index 3b178f33b40..0c3bc43d7c4 100644 --- a/test/managed-image-publication-workflow.test.ts +++ b/test/managed-image-publication-workflow.test.ts @@ -372,20 +372,22 @@ describe("complete managed-image publication workflow", () => { const basePublishers = [ { agent: "hermes", - artifact: "managed-base-${{ github.run_id }}-${{ github.run_attempt }}-hermes", + displayName: "Hermes", + image: "nvidia/nemoclaw/hermes-sandbox-base", job: "build-and-push-hermes", platformsJob: "build-hermes-platforms", }, { agent: "langchain-deepagents-code", - artifact: - "managed-base-${{ github.run_id }}-${{ github.run_attempt }}-langchain-deepagents-code", + displayName: "Deep Agents Code", + image: "nvidia/nemoclaw/langchain-deepagents-code-sandbox-base", job: "build-and-push-dcode", platformsJob: "build-dcode-platforms", }, { agent: "openclaw", - artifact: "managed-base-${{ github.run_id }}-${{ github.run_attempt }}-openclaw", + displayName: "OpenClaw", + image: "nvidia/nemoclaw/sandbox-base", job: "build-and-push-openclaw", platformsJob: "build-openclaw-platforms", }, @@ -397,39 +399,19 @@ 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, "Create and verify multi-platform manifest"); - const manifestRun = manifest.run ?? ""; - expect(manifest.id).toBe("manifest"); - expect(manifest.env?.AGENT).toBe(expectedPublisher.agent); - expect(manifest.run).toContain('reference="$IMAGE@$digest"'); - expect(manifest.run).toContain('.["containerimage.descriptor"].digest'); - expect(manifest.run).toContain('--metadata-file "$candidate_metadata"'); - expect(manifestRun).toContain('scripts/checks/retry-docker-imagetools-inspect.sh "$source"'); - expect(manifestRun).toContain( - 'scripts/checks/retry-docker-imagetools-inspect.sh "$reference" --raw', - ); - expect(manifestRun).not.toContain("docker buildx imagetools inspect"); - expect(manifest.run).toContain("scripts/checks/validate-managed-base-index.sh"); - expect(manifest.run).toContain("declare -A source_digests=()"); - expect(manifest.run).toContain('"${source_digests[linux/amd64]}"'); - expect(manifest.run).toContain('"${source_digests[linux/arm64]}"'); - expect(manifest.run).toContain("platform_digests_json="); - expect(manifest.run).toContain("declare -A platform_digests=()"); - expect(manifest.run).toContain("scripts/export-managed-base-image-contract.sh"); - expect(manifest.run).toContain('"${platform_digests[linux/amd64]}"'); - expect(manifest.run).toContain('"${platform_digests[linux/arm64]}"'); - const validationIndex = manifestRun.indexOf("scripts/checks/validate-managed-base-index.sh"); - const promotionIndex = manifestRun.indexOf("publication_metadata="); - expect(manifestRun.indexOf("candidate_tag=")).toBeLessThan(validationIndex); - expect(validationIndex).toBeLessThan(promotionIndex); - expect(manifestRun).toContain("published_digest="); - expect(manifestRun).toContain('if [ "$published_digest" != "$digest" ]; then'); - expect(manifestRun).not.toContain("first_tag="); - expect(manifestRun).not.toContain('imagetools create "${tag_args[@]}" "${sources[@]}"'); + const manifest = step(basePublisher, "Publish validated multi-platform manifest"); + expect(manifest).toMatchObject({ + uses: "./.github/actions/publish-base-image-manifest", + with: { + agent: expectedPublisher.agent, + "display-name": expectedPublisher.displayName, + image: expectedPublisher.image, + registry: "${{ env.REGISTRY }}", + "registry-username": "${{ github.actor }}", + "registry-password": "${{ secrets.GITHUB_TOKEN }}", + }, + }); expect(step(basePublisher, "Checkout").with?.["persist-credentials"]).toBe(false); - expect(step(basePublisher, "Upload managed base image contract").with?.name).toBe( - expectedPublisher.artifact, - ); const nativePlatforms = required( baseWorkflow.jobs?.[expectedPublisher.platformsJob], diff --git a/test/openclaw-dependency-review.test.ts b/test/openclaw-dependency-review.test.ts index bd085cb61e8..960b18b7825 100644 --- a/test/openclaw-dependency-review.test.ts +++ b/test/openclaw-dependency-review.test.ts @@ -805,9 +805,13 @@ grep -Fq -- '--phase post-agent-install' Dockerfile }); it("accepts reviewed base-image versions and rejects injected build arguments", () => { - const baseImages = readYaml(".github/workflows/base-image.yaml"); - const buildOpenClawPlatforms = baseImages.jobs["build-openclaw-platforms"] as WorkflowJob; - const guard = requiredStep(buildOpenClawPlatforms, "Validate production Docker build args"); + const action = readYaml<{ runs: { steps: WorkflowStep[] } }>( + ".github/actions/build-base-image-platform/action.yaml", + ); + const guard = requiredStep( + { steps: action.runs.steps }, + "Validate production Docker build args", + ); for (const [input, expectedOutput] of [ ["", "openclaw_build_arg=\n"], @@ -844,52 +848,4 @@ grep -Fq -- '--phase post-agent-install' Dockerfile ); } }); - - // source-shape-contract: security -- Network-fetched distribution audits must execute only from trusted main workflow code - it("runs and gates the real patched-distribution harness only from trusted main code", () => { - const pr = readYaml(".github/workflows/pr.yaml"); - const main = readYaml(".github/workflows/main.yaml"); - const prJob = pr.jobs["real-openclaw-dist-harness"]; - const mainJob = main.jobs["real-openclaw-dist-harness"]; - const prChecks = pr.jobs.checks; - const mainChecks = main.jobs.checks; - - expect(pr.permissions).toEqual({ contents: "read" }); - expect(prJob).toBeUndefined(); - expect(requiredStep(mainJob, "Audit the real patched OpenClaw distribution").env).toMatchObject( - { - NEMOCLAW_REAL_OPENCLAW_DIST_HARNESS: "1", - }, - ); - expect(requiredStep(mainJob, "Audit the real patched OpenClaw distribution").run).toContain( - "test/openclaw-real-patched-dist-harness.test.ts", - ); - expect(requiredStep(mainJob, "Verify reviewed Jaeger header handling").env).toEqual({ - NEMOCLAW_REAL_OPENCLAW_JAEGER_HARNESS: "1", - }); - expect(requiredStep(mainJob, "Verify reviewed Jaeger header handling").run).toContain( - "test/openclaw-diagnostics-jaeger-runtime.test.ts", - ); - expect( - requiredStep(mainJob, "Audit managed OpenClaw security finding suppressions").env, - ).toEqual({ NEMOCLAW_REAL_OPENCLAW_AUDIT_HARNESS: "1" }); - expect( - requiredStep(mainJob, "Audit managed OpenClaw security finding suppressions").run, - ).toContain("test/openclaw-security-audit-suppressions-real.test.ts"); - expect(requiredStep(mainJob, "Install test dependencies").run).toBe("npm ci --ignore-scripts"); - expect(prChecks.needs).not.toContain("real-openclaw-dist-harness"); - expect(mainChecks.needs).toContain("real-openclaw-dist-harness"); - const prGate = requiredStep(prChecks, "Verify required PR checks"); - const mainGate = requiredStep(mainChecks, "Verify required main checks"); - expect(prGate.env).not.toHaveProperty("REAL_OPENCLAW_DIST_HARNESS_RESULT"); - expect(mainGate.env).toMatchObject({ - REAL_OPENCLAW_DIST_HARNESS_RESULT: "${{ needs['real-openclaw-dist-harness'].result }}", - }); - - expect(prGate.run).not.toContain("real-openclaw-dist-harness"); - expect(mainGate.run).toContain( - 'require_success "real-openclaw-dist-harness" "$REAL_OPENCLAW_DIST_HARNESS_RESULT"', - ); - expect(mainGate.run).not.toContain('allow_success_or_skipped "real-openclaw-dist-harness"'); - }); }); diff --git a/test/openclaw-locked-install.test.ts b/test/openclaw-locked-install.test.ts index 8312db94355..1fc9a6a3db2 100644 --- a/test/openclaw-locked-install.test.ts +++ b/test/openclaw-locked-install.test.ts @@ -363,58 +363,4 @@ describe("locked OpenClaw production installation (#5896)", () => { expect(contents).toContain('"lock-sha256=${OPENCLAW_LOCK_SHA256}"'); expect(contents).toContain("locked-ci+reviewed-lifecycle-v2"); }); - - // source-shape-contract: security -- The shipped audit and base-image rebuild inputs must share the exact committed production lock authority - it("audits the same lock and rebuilds the base when its graph changes", () => { - const audit = JSON.parse( - fs.readFileSync(path.join(REPO_ROOT, "ci", "reviewed-npm-audit.json"), "utf-8"), - ); - expect(audit.schemaVersion).toBe(2); - expect(audit.archivePackages).toEqual( - expect.arrayContaining([expect.objectContaining({ packageSpec: PACKAGE_SPEC })]), - ); - expect(audit.lockedGraphs).toEqual( - expect.arrayContaining([ - expect.objectContaining({ - directory: "agents/openclaw/openclaw-runtime", - lockSha256: LOCK_SHA256, - packageSpec: PACKAGE_SPEC, - }), - expect.objectContaining({ - directory: "agents/openclaw/mcporter-runtime", - lockSha256: MCPORTER_LOCK_SHA256, - packageSpec: MCPORTER_PACKAGE_SPEC, - }), - expect.objectContaining({ - directory: "tools/mcp-tool-discovery-runtime", - lockSha256: MCP_TOOL_DISCOVERY_LOCK_SHA256, - packageSpec: MCP_TOOL_DISCOVERY_PACKAGE_SPEC, - }), - ]), - ); - expect(audit.lockedGraphs).toHaveLength(3); - expect( - audit.lockedGraphs.map(({ packageSpec }: { packageSpec?: string }) => packageSpec).sort(), - ).toEqual([MCPORTER_PACKAGE_SPEC, MCP_TOOL_DISCOVERY_PACKAGE_SPEC, PACKAGE_SPEC].sort()); - for (const graph of audit.lockedGraphs) { - expect(graph).not.toHaveProperty("replacementLockSha256"); - expect(graph).not.toHaveProperty("reviewedLockSha256"); - } - - const baseWorkflow = fs.readFileSync( - path.join(REPO_ROOT, ".github", "workflows", "base-image.yaml"), - "utf-8", - ); - expect(baseWorkflow).toContain('"agents/openclaw/openclaw-runtime/package.json"'); - expect(baseWorkflow).toContain('"agents/openclaw/openclaw-runtime/package-lock.json"'); - expect(baseWorkflow).toContain('"scripts/lib/reviewed-npm-archive.mts"'); - - const baseResolver = fs.readFileSync( - path.join(REPO_ROOT, ".github", "actions", "resolve-sandbox-base-image", "action.yaml"), - "utf-8", - ); - expect(baseResolver).toContain("agents/openclaw/openclaw-runtime/package.json"); - expect(baseResolver).toContain("agents/openclaw/openclaw-runtime/package-lock.json"); - expect(baseResolver).toContain("scripts/lib/reviewed-npm-archive.mts"); - }); }); diff --git a/test/openshell-e2e-qualification-workflow.test.ts b/test/openshell-e2e-qualification-workflow.test.ts deleted file mode 100644 index 109ae3a71a2..00000000000 --- a/test/openshell-e2e-qualification-workflow.test.ts +++ /dev/null @@ -1,65 +0,0 @@ -// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. -// SPDX-License-Identifier: Apache-2.0 - -import assert from "node:assert/strict"; - -import { describe, expect, it } from "vitest"; - -import { type CompositeAction, readYaml, type WorkflowJob } from "./helpers/e2e-workflow-contract"; - -type InstallerHashWorkflow = { - permissions?: Record; - jobs: Record; -}; - -type InstallerHashAction = CompositeAction & { inputs?: Record }; - -function requiredWorkflowStep(job: WorkflowJob, name: string) { - const step = job.steps?.find((candidate) => candidate.name === name); - assert(step, `Missing workflow step: ${name}`); - return step; -} - -describe("installer hash workflow", () => { - const workflow = readYaml(".github/workflows/installer-hash-check.yaml"); - const action = readYaml( - ".github/actions/ci-installer-hash-check/action.yaml", - ); - - // source-shape-contract: security -- Installer integrity verification must remain independent from unrelated full E2E completion - it("keeps installer verification independent from full E2E qualification", () => { - const checkHashJob = workflow.jobs["check-hash"]; - const baseCheckout = requiredWorkflowStep( - checkHashJob, - "Checkout base-trusted installer hash action", - ); - const bootstrapCheckout = requiredWorkflowStep( - checkHashJob, - "Checkout immutable installer hash bootstrap", - ); - - expect(workflow.permissions).toEqual({ contents: "read" }); - expect(checkHashJob["timeout-minutes"]).toBe(5); - const sparseCheckout = baseCheckout.with?.["sparse-checkout"]; - assert(typeof sparseCheckout === "string"); - expect(sparseCheckout.trim().split("\n")).toEqual([ - ".github/actions/ci-installer-hash-check", - "scripts/check-installer-hash.sh", - "scripts/checks/extract-installer-pins.mts", - ]); - const bootstrapSparseCheckout = bootstrapCheckout.with?.["sparse-checkout"]; - assert(typeof bootstrapSparseCheckout === "string"); - expect(bootstrapSparseCheckout.trim().split("\n")).toEqual([ - ".github/actions/ci-installer-hash-check", - "scripts/check-installer-hash.sh", - "scripts/checks/extract-installer-pins.mts", - ]); - expect(action.inputs?.["repo-root"]?.required).toBe(true); - expect(action.runs.steps.map((step) => step.name)).toEqual([ - "Verify installer hashes are current", - ]); - expect(JSON.stringify({ action, workflow })).not.toContain( - "verify-openshell-e2e-qualification", - ); - }); -}); diff --git a/test/pr-limit-policy.test.ts b/test/pr-limit-policy.test.ts deleted file mode 100644 index 1a8b89ec17e..00000000000 --- a/test/pr-limit-policy.test.ts +++ /dev/null @@ -1,43 +0,0 @@ -// 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 { expect, it } from "vitest"; -import YAML from "yaml"; - -const repoRoot = process.cwd(); - -function read(relativePath: string): string { - return fs.readFileSync(path.join(repoRoot, relativePath), "utf8"); -} - -function documentedLimit(source: string): number | undefined { - const match = source.match(/(?:up to|the) (\d+)(?:-open-PR| open PRs)/iu); - return match ? Number(match[1]) : undefined; -} - -// source-shape-contract: compatibility -- Contributor guidance must match the enforced PR count and preserve the workflow-owned maintainer exemption -it("keeps contributor guidance aligned with the enforced maintainer exemption", () => { - const workflow = YAML.parse(read(".github/workflows/pr-limit.yaml")) as { - jobs: Record }>; - }; - const run = workflow.jobs["check-pr-limit"]?.steps.find((step) => step.run)?.run ?? ""; - const enforcedLimit = Number(run.match(/\$OPEN_COUNT" -gt (\d+)/u)?.[1]); - const exemptAccounts = - run - .match(/EXEMPT="([^"]+)"/u)?.[1] - .trim() - .split(/\s+/u) ?? []; - const agents = read("AGENTS.md"); - const contributing = read("CONTRIBUTING.md"); - - expect(enforcedLimit).toBe(10); - expect(exemptAccounts.length).toBeGreaterThan(0); - expect(documentedLimit(agents)).toBe(enforcedLimit); - expect(documentedLimit(contributing)).toBe(enforcedLimit); - expect(agents).toContain("only to accounts that the workflow does not exempt"); - expect(contributing).toContain( - "Core maintainers listed in `.github/workflows/pr-limit.yaml` are exempt from this limit.", - ); -}); diff --git a/test/pr-merge-conflict-fixer-workflow-boundary.test.ts b/test/pr-merge-conflict-fixer-workflow-boundary.test.ts index a959552335c..4283fd284a4 100644 --- a/test/pr-merge-conflict-fixer-workflow-boundary.test.ts +++ b/test/pr-merge-conflict-fixer-workflow-boundary.test.ts @@ -62,10 +62,9 @@ describe("PR merge conflict fixer workflow boundary", () => { expect(scan.permissions).toEqual({ contents: "read", "pull-requests": "read" }); expect(resolve.permissions).toEqual({ contents: "read" }); expect(publish.permissions).toEqual({ contents: "write", "pull-requests": "read" }); + expect(scan["timeout-minutes"]).toBe(10); expect(resolve["timeout-minutes"]).toBe(30); - expect( - Object.values(jobs).filter((job) => record(job)["timeout-minutes"] !== undefined), - ).toHaveLength(1); + expect(publish["timeout-minutes"]).toBe(10); }); it("loads each resolve command from the pushed main SHA (#6952)", () => { diff --git a/test/pr-workflow-contract.test.ts b/test/pr-workflow-contract.test.ts index 8a9e1391b4a..f9957c76638 100644 --- a/test/pr-workflow-contract.test.ts +++ b/test/pr-workflow-contract.test.ts @@ -313,207 +313,6 @@ describe("pull request and main workflow contracts", () => { ), }; - // source-shape-contract: security -- Base retargets must rerun trusted installer verification without minting skipped required evidence - it("reruns installer hash verification after a pull request base retarget", () => { - expect(installerHashWorkflow.on?.pull_request?.types).toEqual([ - "opened", - "synchronize", - "reopened", - "edited", - ]); - expect(installerHashWorkflow.jobs["check-hash"].if).toBe( - "github.repository == 'NVIDIA/NemoClaw'", - ); - }); - - // source-shape-contract: security -- Dependabot's bounded DCO exemption must report an explicit successful required check - it("records the Dependabot DCO bypass as a successful required job", () => { - const job = dcoWorkflow.jobs["dco-check"]; - const bypass = requiredWorkflowStep(job, "Check Dependabot DCO bypass"); - const declaration = requiredWorkflowStep(job, "Check PR body for Signed-off-by"); - - expect(job.if).toBeUndefined(); - expect(job.steps?.some((step) => step.uses?.startsWith("actions/checkout@"))).toBe(false); - expect(bypass.env?.USERNAME).toBe("${{ github.event.pull_request.user.login }}"); - expect(bypass.run).toContain('"$USERNAME" == "dependabot[bot]"'); - expect(bypass.run).toContain('"$USERNAME" == "app/dependabot"'); - expect(bypass.run).not.toContain(".github/dco-bypass.txt"); - expect(declaration.if).toBe("${{ steps.dco-bypass.outputs.bypass != 'true' }}"); - }); - - // source-shape-contract: security -- Installer hashes must be verified by base-trusted or immutable bootstrap code - it("runs pull request installer verification from immutable trusted code", () => { - const job = installerHashWorkflow.jobs["check-hash"]; - const parserRuntimeSetup = requiredWorkflowStep( - job, - "Set up trusted installer hash parser runtime", - ); - const prCheckout = requiredWorkflowStep(job, "Checkout pull request head"); - const baseCheckout = requiredWorkflowStep(job, "Checkout base-trusted installer hash action"); - const trustedActionProbe = requiredWorkflowStep( - job, - "Detect base-trusted installer hash action", - ); - const bootstrapCheckout = requiredWorkflowStep( - job, - "Checkout immutable installer hash bootstrap", - ); - const bootstrapTreeVerification = requiredWorkflowStep( - job, - "Verify immutable installer hash bootstrap tree", - ); - const bootstrapExpiry = requiredWorkflowStep( - job, - "Enforce immutable installer hash bootstrap expiry", - ); - const baseVerification = requiredWorkflowStep( - job, - "Verify pull request installer hashes from base-trusted code", - ); - const bootstrapVerification = requiredWorkflowStep( - job, - "Verify pull request installer hashes from immutable bootstrap", - ); - const trustedEventVerification = requiredWorkflowStep( - job, - "Verify trusted event installer hashes", - ); - - expect(installerHashWorkflow.on?.pull_request?.paths).toBeUndefined(); - expect(installerHashWorkflow.on?.pull_request?.types).toEqual([ - "opened", - "synchronize", - "reopened", - "edited", - ]); - expect(installerHashWorkflow["run-name"]).toContain( - "Installer Hash PR #{0} head {1} base {2} gate true", - ); - expect(installerHashWorkflow["run-name"]).toContain("github.event.pull_request.base.sha"); - expect(installerHashWorkflow["run-name"]).not.toContain("github.event.changes.base"); - expect(job.if).toBe("github.repository == 'NVIDIA/NemoClaw'"); - expect(parserRuntimeSetup.uses).toBe(trustedSetupNodeAction); - expect(parserRuntimeSetup.with?.["node-version"]).toBe("22.19.0"); - expect(prCheckout.with?.repository).toBe( - "${{ github.event.pull_request.head.repo.full_name }}", - ); - expect(prCheckout.with?.ref).toBe("${{ github.event.pull_request.head.sha }}"); - - for (const checkout of (job.steps ?? []).filter( - (step) => step.uses === trustedCheckoutAction, - )) { - expect(checkout.with?.["persist-credentials"], checkout.name).toBe(false); - } - expect( - (job.steps ?? []) - .filter((step) => step.uses?.startsWith("actions/checkout@")) - .every((step) => step.uses === trustedCheckoutAction), - ).toBe(true); - - expect(baseCheckout.with?.ref).toBe("${{ github.event.pull_request.base.sha }}"); - expect(baseCheckout.with?.path).toBe(".trusted-installer-hash"); - expect(baseCheckout.with?.["sparse-checkout"]).toContain( - ".github/actions/ci-installer-hash-check", - ); - expect(baseCheckout.with?.["sparse-checkout"]).toContain("scripts/check-installer-hash.sh"); - expect(baseCheckout.with?.["sparse-checkout"]).toContain( - "scripts/checks/extract-installer-pins.mts", - ); - - expect(trustedActionProbe.id).toBe("trusted-installer-hash"); - expect(trustedActionProbe.run).toContain( - ".trusted-installer-hash/.github/actions/ci-installer-hash-check/action.yaml", - ); - expect(trustedActionProbe.run).not.toContain("scripts/check-installer-hash.sh"); - expect(bootstrapCheckout.with?.ref).toBe(installerHashBootstrapCommit); - expect(String(bootstrapCheckout.with?.ref)).toMatch(/^[a-f0-9]{40}$/u); - expect(bootstrapCheckout.with?.path).toBe(".bootstrap-installer-hash"); - expect(bootstrapCheckout.with?.["sparse-checkout"]).toContain( - ".github/actions/ci-installer-hash-check", - ); - expect(bootstrapCheckout.with?.["sparse-checkout"]).toContain( - "scripts/check-installer-hash.sh", - ); - expect(bootstrapCheckout.with?.["sparse-checkout"]).toContain( - "scripts/checks/extract-installer-pins.mts", - ); - expect(bootstrapCheckout.with?.["sparse-checkout-cone-mode"]).toBe(false); - expect((bootstrapExpiry as WorkflowStep & { shell?: string }).shell).toBe("bash"); - expect(bootstrapExpiry.env).toBeUndefined(); - expect(bootstrapExpiry.run).toContain(installerHashBootstrapCommit); - expect(bootstrapExpiry.run).toContain(installerHashBootstrapExpiresAt); - expect(bootstrapExpiry.if).toBe(bootstrapCheckout.if); - expect(bootstrapExpiry.if).toBe(bootstrapVerification.if); - expect(bootstrapTreeVerification.if).toBe(bootstrapCheckout.if); - expect(bootstrapTreeVerification.run).toContain(installerHashBootstrapCommit); - expect(bootstrapTreeVerification.run).toContain(installerHashBootstrapTree); - expect( - requiredWorkflowStepIndex(job, "Enforce immutable installer hash bootstrap expiry"), - ).toBeLessThan(requiredWorkflowStepIndex(job, "Checkout immutable installer hash bootstrap")); - expect( - requiredWorkflowStepIndex(job, "Checkout immutable installer hash bootstrap"), - ).toBeLessThan( - requiredWorkflowStepIndex(job, "Verify immutable installer hash bootstrap tree"), - ); - expect( - requiredWorkflowStepIndex(job, "Verify immutable installer hash bootstrap tree"), - ).toBeLessThan( - requiredWorkflowStepIndex( - job, - "Verify pull request installer hashes from immutable bootstrap", - ), - ); - expect( - requiredWorkflowStepIndex(job, "Set up trusted installer hash parser runtime"), - ).toBeLessThan( - requiredWorkflowStepIndex(job, "Verify pull request installer hashes from base-trusted code"), - ); - expect( - requiredWorkflowStepIndex(job, "Set up trusted installer hash parser runtime"), - ).toBeLessThan( - requiredWorkflowStepIndex( - job, - "Verify pull request installer hashes from immutable bootstrap", - ), - ); - expect( - requiredWorkflowStepIndex(job, "Set up trusted installer hash parser runtime"), - ).toBeLessThan(requiredWorkflowStepIndex(job, "Verify trusted event installer hashes")); - expect( - (Date.parse(installerHashBootstrapExpiresAt) - Date.parse(installerHashBootstrapCreatedAt)) / - 86_400_000, - ).toBe(180); - expect(bootstrapExpiry.run).toContain("Date.now() >= expiresAtMs"); - expect(bootstrapExpiry.run).toContain("Remove the bootstrap fallback"); - - expect(baseVerification.uses).toBe( - "./.trusted-installer-hash/.github/actions/ci-installer-hash-check", - ); - expect(bootstrapVerification.uses).toBe( - "./.bootstrap-installer-hash/.github/actions/ci-installer-hash-check", - ); - expect(trustedEventVerification.uses).toBe("./.github/actions/ci-installer-hash-check"); - expect(baseVerification.if).toBe( - "github.event_name == 'pull_request' && steps.trusted-installer-hash.outputs.available == 'true'", - ); - expect(bootstrapVerification.if).toBe( - "github.event_name == 'pull_request' && steps.trusted-installer-hash.outputs.available != 'true'", - ); - expect(trustedEventVerification.if).toBe("github.event_name != 'pull_request'"); - for (const verification of [ - baseVerification, - bootstrapVerification, - trustedEventVerification, - ]) { - expect(verification.with?.["repo-root"], verification.name).toBe("${{ github.workspace }}"); - } - - expect(job.steps?.some((step) => step.name === "Detect installer-affecting changes")).toBe( - false, - ); - expect(stepRuns(job).join("\n")).not.toContain("bash scripts/check-installer-hash.sh"); - }); - it("fails closed when the immutable installer hash bootstrap expiry is mutated", () => { const expiryStep = requiredWorkflowStep( installerHashWorkflow.jobs["check-hash"], @@ -594,540 +393,6 @@ describe("pull request and main workflow contracts", () => { } }); - // source-shape-contract: security -- The trusted action must invoke its bundled verifier without PR-controlled resolution - it("keeps the installer verifier inside the trusted composite action", () => { - const verification = requiredStep(installerHashAction, "Verify installer hashes are current"); - - expect(installerHashAction.inputs?.["repo-root"]?.required).toBe(true); - expect(verification.env).toEqual({ - NEMOCLAW_INSTALLER_HASH_REPO_ROOT: "${{ inputs.repo-root }}", - }); - expect(verification.run).toBe( - 'bash "${{ github.action_path }}/../../../scripts/check-installer-hash.sh"', - ); - }); - - // source-shape-contract: compatibility -- Path-filter semantics keep documentation-only and code-changing PR lanes distinct - it("routes only code-changing PRs through the code-check path", () => { - const filterStep = prWorkflow.jobs.changes.steps?.find((step) => step.id === "filter"); - const docsOnlyCheckout = requiredWorkflowStep(prWorkflow.jobs["docs-only-checks"], "Checkout"); - - expect(filterStep?.uses).toContain("dorny/paths-filter"); - expect(docsOnlyCheckout.with?.["fetch-depth"]).toBe(0); - expect(filterStep?.with?.["predicate-quantifier"]).toBe("every"); - expect(filterStep?.with?.filters).toContain("code:"); - expect(filterStep?.with?.filters).toContain("!**/*.md"); - expect(filterStep?.with?.filters).toContain("!docs/**"); - - expect(codeFilterMatchesChangedPaths(prWorkflow, ["docs/get-started/prerequisites.mdx"])).toBe( - false, - ); - expect(codeFilterMatchesChangedPaths(prWorkflow, ["README.md"])).toBe(false); - expect(codeFilterMatchesChangedPaths(prWorkflow, ["src/lib/runner.ts"])).toBe(true); - expect( - codeFilterMatchesChangedPaths(prWorkflow, [ - "docs/get-started/prerequisites.mdx", - "src/lib/runner.ts", - ]), - ).toBe(true); - }); - - // source-shape-contract: compatibility -- Repository validation must preserve file routing, command scopes, and aliases - it("preserves repository validation file routing, command scopes, and compatibility aliases", () => { - const hooks = prekConfig.repos.flatMap((repo) => repo.hooks ?? []); - const repositoryChecks = hooks.find((candidate) => candidate.id === "repository-checks"); - const files = new RegExp(repositoryChecks?.files ?? "(?!)", "u"); - - for (const path of [ - ".pre-commit-config.yaml", - "Dockerfile", - "Dockerfile.base", - "agents/openclaw/manifest.yaml", - "agents/hermes/Dockerfile", - "agents/hermes/Dockerfile.base", - "agents/hermes/manifest.yaml", - "agents/hermes/mcp-config-transaction.py", - "nemoclaw-blueprint/blueprint.yaml", - "nemoclaw/package.json", - "package.json", - "scripts/brev-launchable-ci-cpu.sh", - "scripts/check-installer-hash.sh", - "scripts/install-openshell.sh", - "scripts/update-hermes-agent.sh", - "src/lib/actions/sandbox/mcp-bridge-validation.ts", - "src/lib/actions/sandbox/openshell-child-visible-credentials.v0.0.99.json", - ]) { - expect(files.test(path), path).toBe(true); - } - expect(files.test("dependency-pins.yaml")).toBe(false); - expect(files.test("docs/reference/commands.mdx")).toBe(false); - - const scripts = packageJson.scripts; - const cliCoverageCalls = runLoggedPackageScript(scripts["test:coverage:cli"]); - const pluginCoverageCalls = runLoggedPackageScript(scripts["test:coverage:plugin"]); - const broadCheckCalls = runLoggedPackageScript(scripts.check); - const routinePrCalls = runLoggedPackageScript(scripts["validate:pr"]); - const repositoryCheckCalls = runLoggedPackageScript(scripts["checks:repository"]); - const legacyRepositoryChecks = runLoggedPackageScriptWithOutput(scripts.checks); - - expect(cliCoverageCalls.map(([command]) => command)).toEqual( - "npm npm tsx vitest tsx".split(" "), - ); - expect(cliCoverageCalls[3]).toEqual( - expect.arrayContaining(["--project", "cli", "integration", "--coverage"]), - ); - expect(cliCoverageCalls[4]).toEqual( - "tsx|scripts/check-coverage-ratchet.mts|coverage/cli/coverage-summary.json|ci/coverage-threshold-cli.json|CLI coverage".split( - "|", - ), - ); - expect(pluginCoverageCalls[0]).toEqual( - expect.arrayContaining([ - "--project", - "plugin", - "--coverage.include=nemoclaw/src/**/*.ts", - "--coverage.include=nemoclaw/src/**/*.cts", - ]), - ); - expect(pluginCoverageCalls[1]).toEqual( - "tsx|scripts/check-coverage-ratchet.mts|coverage/plugin/coverage-summary.json|ci/coverage-threshold-plugin.json|Plugin coverage".split( - "|", - ), - ); - expect(broadCheckCalls.map((call) => call.join(" "))).toEqual([ - "npx prek run --all-files --stage pre-commit", - "npx prek run --all-files --stage manual", - ]); - expect(routinePrCalls.map((call) => call.join(" "))).toEqual([ - "npx prek run --from-ref origin/main --to-ref HEAD --stage pre-commit", - "npx commitlint --from origin/main --to HEAD", - "npx prek run --from-ref origin/main --to-ref HEAD --stage pre-push", - ]); - expect(repositoryCheckCalls.map((call) => call.join(" "))).toEqual([ - "tsx scripts/checks/run.mts", - ]); - expect(scripts["check:diff"]).toBe("npm run validate:pr"); - expect(legacyRepositoryChecks.calls.map((call) => call.join(" "))).toEqual([ - "npm run checks:repository", - ]); - expect(legacyRepositoryChecks.stderr).toContain("npm run validate:pr"); - expect(legacyRepositoryChecks.stderr).toContain("npm run checks:repository"); - expect(legacyRepositoryChecks.stderr).toContain("runs only narrow repository checks"); - expect(scripts.lint).toContain("npm run checks:repository"); - expect(scripts["lint:fix"]).toContain("npm run checks:repository"); - expect(repositoryChecks?.entry).toBe("npm run checks:repository"); - }); - - // source-shape-contract: compatibility -- Pre-commit routing must apply the declarative guard to every supported test location - it("runs the source-shape guard for root and co-located tests", () => { - const hooks = prekConfig.repos.flatMap((repo) => repo.hooks ?? []); - const sourceShape = hooks.find((candidate) => candidate.id === "source-shape-test-budget"); - const files = new RegExp(sourceShape?.files ?? "(?!)", "u"); - - expect(sourceShape?.entry).toBe("npm run source-shape:check"); - for (const path of [ - "test/example.test.ts", - "src/lib/example.spec.ts", - "nemoclaw/src/example.test.ts", - "scripts/find-source-shape-tests.mts", - "ci/source-shape-test-budget.json", - ]) { - expect(files.test(path), path).toBe(true); - } - expect(files.test("src/lib/example.ts")).toBe(false); - }); - - // source-shape-contract: compatibility -- Changed-file routing must typecheck each project and its transitive configuration inputs - it("scopes pre-push typechecks to project and transitive inputs", () => { - const hooks = prekConfig.repos.flatMap((repo) => repo.hooks ?? []); - const pluginTypecheck = hooks.find((candidate) => candidate.id === "tsc-plugin"); - const cliTypecheck = hooks.find((candidate) => candidate.id === "tsc-cli"); - const jsTypecheck = hooks.find((candidate) => candidate.id === "tsc-js"); - const pluginFiles = new RegExp(pluginTypecheck?.files ?? "(?!)", "u"); - const files = new RegExp(cliTypecheck?.files ?? "(?!)", "u"); - const jsFiles = new RegExp(jsTypecheck?.files ?? "(?!)", "u"); - - expect(pluginTypecheck?.entry).toBe("npm --prefix nemoclaw run typecheck"); - expect(cliTypecheck?.entry).toBe("npm run typecheck:cli -- --incremental"); - expect(cliTypecheck?.always_run).toBeUndefined(); - for (const include of cliTypeScriptConfig.include) { - const representativeInput = include.replace("**/*", "nested/input"); - expect(files.test(representativeInput), include).toBe(true); - } - for (const path of [ - ".agents/skills/nemoclaw-maintainer-day/scripts/check-gates.ts", - ".agents/skills/nemoclaw-maintainer-day/scripts/shared.ts", - "agents/hermes/generate-config.ts", - "bin/nemoclaw.ts", - "scripts/check.ts", - "scripts/check.mts", - "src/lib/runner.ts", - "test/runner.test.ts", - "tools/e2e/workflow-boundary.mts", - "nemoclaw/src/lib/subprocess-env.ts", - "nemoclaw/src/blueprint/private-networks.ts", - "nemoclaw-blueprint/scripts/render.ts", - "src/lib/actions/sandbox/credentials.json", - "package.json", - "package-lock.json", - "tsconfig.cli.json", - "vitest.config.ts", - ]) { - expect(files.test(path), path).toBe(true); - } - for (const path of [ - ".agents/skills/example/scripts/unchecked.ts", - "agents/hermes/start.sh", - "docs/get-started/quickstart.mdx", - "nemoclaw/src/commands/status.ts", - "scripts/check.js", - ]) { - expect(files.test(path), path).toBe(false); - } - for (const path of [ - "nemoclaw/src/lib/subprocess-env.ts", - "nemoclaw/src/blueprint/private-networks.ts", - "nemoclaw/src/commands/status.ts", - ]) { - expect(pluginFiles.test(path), path).toBe(true); - } - expect(pluginFiles.test(".agents/skills/example/scripts/unchecked.ts")).toBe(false); - for (const path of ["bin/nemoclaw.js", "jsconfig.json", "package.json", "package-lock.json"]) { - expect(jsFiles.test(path), path).toBe(true); - } - expect(jsFiles.test("docs/_components/nemoclaw.js")).toBe(false); - }); - - // source-shape-contract: security -- Pull requests must execute base-trusted actions while main uses reviewed repository actions - it("reuses the same shared CI actions in PR and main workflows", () => { - expect(prWorkflow.on?.pull_request?.types).toEqual([ - "opened", - "synchronize", - "reopened", - "edited", - ]); - expect(prWorkflow["run-name"]).toBe( - "CI PR #${{ github.event.pull_request.number }} head ${{ github.event.pull_request.head.sha }} base ${{ github.event.pull_request.base.sha }} gate ${{ github.event.action != 'edited' || github.event.changes.base != null }}", - ); - expect(prWorkflow.concurrency).toEqual({ - group: - "${{ github.workflow }}-${{ github.ref }}-${{ github.event.action != 'edited' || github.event.changes.base != null }}", - "cancel-in-progress": true, - }); - expect( - requiredWorkflowStep(prWorkflow.jobs["static-checks"], "Checkout").with?.["fetch-depth"], - ).toBe(0); - for (const [jobName, stepName, trustedActionPath, mainActionPath] of [ - [ - "static-checks", - "Run static checks", - trustedPrActionPaths.staticChecks, - sharedActionPaths.staticChecks, - ], - [ - "build-typecheck", - "Run build and type checks", - trustedPrActionPaths.buildTypecheck, - sharedActionPaths.buildTypecheck, - ], - [ - "cli-test-shards", - "Run CLI coverage shard", - trustedPrActionPaths.cliCoverageShard, - sharedActionPaths.cliCoverageShard, - ], - [ - "cli-tests", - "Merge CLI coverage", - trustedPrActionPaths.cliCoverageMerge, - sharedActionPaths.cliCoverageMerge, - ], - [ - "plugin-tests", - "Run plugin coverage", - trustedPrActionPaths.pluginCoverage, - sharedActionPaths.pluginCoverage, - ], - ] as const) { - expect(stepUses(prWorkflow.jobs[jobName]), `PR ${jobName}`).toContain(trustedActionPath); - expect(stepUses(mainWorkflow.jobs[jobName]), `main ${jobName}`).toContain(mainActionPath); - expect(stepUses(prWorkflow.jobs[jobName]), `PR ${jobName}`).not.toContain(mainActionPath); - expect(stepUses(mainWorkflow.jobs[jobName]), `main ${jobName}`).not.toContain( - trustedActionPath, - ); - - const trustedCheckout = requiredWorkflowStep( - prWorkflow.jobs[jobName], - "Checkout trusted CI actions", - ); - expect(trustedCheckout.uses).toBe(trustedCheckoutAction); - expect(trustedCheckout.with?.ref).toBe("${{ github.event.pull_request.base.sha }}"); - expect(trustedCheckout.with?.path).toBe(".trusted-ci-actions"); - expect(trustedCheckout.with?.["persist-credentials"]).toBe(false); - expect(trustedCheckout.with?.["sparse-checkout-cone-mode"]).toBe(false); - for (const trustedActionDir of trustedActionDirs) { - expect(String(trustedCheckout.with?.["sparse-checkout"])).toContain(trustedActionDir); - } - expect( - requiredWorkflowStepIndex(prWorkflow.jobs[jobName], "Checkout trusted CI actions"), - ).toBeLessThan(requiredWorkflowStepIndex(prWorkflow.jobs[jobName], stepName)); - } - - expect(stepUses(prWorkflow.jobs["installer-integration"])).toContain( - trustedPrActionPaths.installerIntegration, - ); - expect(stepUses(prWorkflow.jobs["installer-integration"])).not.toContain( - sharedActionPaths.installerIntegration, - ); - expect(stepUses(mainWorkflow.jobs["installer-integration"])).toContain( - sharedActionPaths.installerIntegration, - ); - expect(stepUses(mainWorkflow.jobs["installer-integration"])).not.toContain( - trustedPrActionPaths.installerIntegration, - ); - const installerTrustedCheckout = requiredWorkflowStep( - prWorkflow.jobs["installer-integration"], - "Checkout trusted CI actions", - ); - expect(installerTrustedCheckout.uses).toBe(trustedCheckoutAction); - expect(installerTrustedCheckout.with?.ref).toBe("${{ github.event.pull_request.base.sha }}"); - expect(installerTrustedCheckout.with?.path).toBe(".trusted-ci-actions"); - expect(installerTrustedCheckout.with?.["persist-credentials"]).toBe(false); - expect(installerTrustedCheckout.with?.["sparse-checkout-cone-mode"]).toBe(false); - expect(String(installerTrustedCheckout.with?.["sparse-checkout"])).toContain( - ".github/actions/ci-installer-integration", - ); - const installerActionProbe = requiredWorkflowStep( - prWorkflow.jobs["installer-integration"], - "Detect trusted installer integration action", - ); - expect(installerActionProbe.id).toBe("trusted-installer-integration"); - expect(installerActionProbe.run).toContain( - ".trusted-ci-actions/.github/actions/ci-installer-integration/action.yaml", - ); - expect(installerActionProbe.run).toContain("available=true"); - expect(installerActionProbe.run).toContain("available=false"); - const installerActionStep = requiredWorkflowStep( - prWorkflow.jobs["installer-integration"], - "Run installer integration tests", - ); - expect(installerActionStep.if).toBe( - "${{ steps.trusted-installer-integration.outputs.available == 'true' }}", - ); - const bootstrapSetup = requiredWorkflowStep( - prWorkflow.jobs["installer-integration"], - "Setup Node.js for installer integration", - ); - expect(bootstrapSetup.if).toBe( - "${{ steps.trusted-installer-integration.outputs.available != 'true' }}", - ); - expect(bootstrapSetup.uses).toBe(trustedSetupNodeAction); - expect(bootstrapSetup.with?.["node-version"]).toBe("22"); - expect(bootstrapSetup.with?.cache).toBe("npm"); - const bootstrapInstall = requiredWorkflowStep( - prWorkflow.jobs["installer-integration"], - "Install installer integration dependencies", - ); - expect(bootstrapInstall.if).toBe( - "${{ steps.trusted-installer-integration.outputs.available != 'true' }}", - ); - expect(bootstrapInstall.run).toContain("npm install --ignore-scripts"); - expect(bootstrapInstall.run).toContain("cd nemoclaw && npm install --ignore-scripts"); - const bootstrapBuild = requiredWorkflowStep( - prWorkflow.jobs["installer-integration"], - "Build installer integration artifacts", - ); - expect(bootstrapBuild.if).toBe( - "${{ steps.trusted-installer-integration.outputs.available != 'true' }}", - ); - expect(bootstrapBuild.run).toContain("npm run build:cli"); - expect(bootstrapBuild.run).toContain("cd nemoclaw && npm run build"); - const bootstrapRun = requiredWorkflowStep( - prWorkflow.jobs["installer-integration"], - "Run installer integration tests (bootstrap)", - ); - expect(bootstrapRun.if).toBe( - "${{ steps.trusted-installer-integration.outputs.available != 'true' }}", - ); - expect(bootstrapRun.run).toBe("CI=true npx vitest run --project installer-integration"); - expect( - requiredWorkflowStepIndex( - prWorkflow.jobs["installer-integration"], - "Checkout trusted CI actions", - ), - ).toBeLessThan( - requiredWorkflowStepIndex( - prWorkflow.jobs["installer-integration"], - "Run installer integration tests", - ), - ); - expect( - requiredWorkflowStepIndex( - prWorkflow.jobs["installer-integration"], - "Detect trusted installer integration action", - ), - ).toBeLessThan( - requiredWorkflowStepIndex( - prWorkflow.jobs["installer-integration"], - "Run installer integration tests (bootstrap)", - ), - ); - - expect(stepUses(mainWorkflow.jobs.checks)).not.toContain("./.github/actions/basic-checks"); - expect(prWorkflow.jobs["cli-test-shards"].strategy?.["fail-fast"]).toBe(false); - expect(mainWorkflow.jobs["cli-test-shards"].strategy?.["fail-fast"]).toBe(false); - expect(prWorkflow.jobs["cli-test-shards"].strategy?.matrix?.shard).toEqual([...cliShardMatrix]); - expect(mainWorkflow.jobs["cli-test-shards"].strategy?.matrix?.shard).toEqual([ - ...cliShardMatrix, - ]); - for (const [workflowName, workflow] of [ - ["pull_request", prWorkflow], - ["main", mainWorkflow], - ] as const) { - expect(workflow.jobs["cli-test-shards"]["timeout-minutes"], workflowName).toBe(15); - const checkoutStep = requiredWorkflowStep(workflow.jobs["cli-test-shards"], "Checkout"); - const shardStep = requiredWorkflowStep( - workflow.jobs["cli-test-shards"], - "Run CLI coverage shard", - ); - const mergeStep = requiredWorkflowStep(workflow.jobs["cli-tests"], "Merge CLI coverage"); - expect(checkoutStep.with?.["fetch-depth"], `${workflowName} checkout depth`).toBe(0); - expect(shardStep.with?.shard, `${workflowName} shard input`).toBe("${{ matrix.shard }}"); - expect(shardStep.with?.["shard-count"], `${workflowName} shard-count input`).toBe( - cliShardCount, - ); - expect(mergeStep.with?.["shard-count"], `${workflowName} merge shard-count`).toBe( - cliShardCount, - ); - expect(workflow.jobs["cli-tests"].permissions?.actions, workflowName).toBe("read"); - expect(workflow.jobs.checks.permissions?.actions, workflowName).toBe("read"); - } - }); - - // source-shape-contract: security -- Base-trusted PR sharding must retain hermetic coverage while retired duplicate lanes stay absent - it("folds hermetic E2E support and Ollama proxy coverage into existing Vitest lanes", () => { - const shardRun = requiredStep( - sharedActions.cliCoverageShard, - "Run CLI coverage and E2E support shard", - ); - expect(shardRun.run).toContain("--project cli --project integration --project e2e-support"); - - const parityStep = requiredStep( - sharedActions.cliCoverageShard, - "Validate changed live E2E mock parity", - ); - expect(parityStep.if).toBe("${{ inputs.shard == '1' }}"); - expect(parityStep.run).toContain("base=HEAD^1"); - expect(parityStep.run).toContain("head=HEAD^2"); - expect(parityStep.run).toContain('base="$PUSH_BASE_SHA"'); - expect(parityStep.run).toContain("npx tsx scripts/checks/e2e-mock-parity.mts"); - expect(parityStep.run).not.toContain("scripts/checks/e2e-mock-parity.ts"); - const trustedCapabilityProbe = requiredWorkflowStep( - prWorkflow.jobs["cli-test-shards"], - "Detect trusted E2E support sharding", - ); - expect(trustedCapabilityProbe.id).toBe("trusted-shard-capabilities"); - expect(trustedCapabilityProbe.run).toContain("--project e2e-support"); - expect(trustedCapabilityProbe.run).toContain("e2e-support=true"); - expect(trustedCapabilityProbe.run).toContain("e2e-support=false"); - - const bootstrapParity = requiredWorkflowStep( - prWorkflow.jobs["cli-test-shards"], - "Validate changed live E2E mock parity (bootstrap)", - ); - expect(bootstrapParity.if).toBe( - "${{ steps.trusted-shard-capabilities.outputs.e2e-support != 'true' && matrix.shard == 1 }}", - ); - expect(bootstrapParity.run).toContain("--base HEAD^1 --head HEAD^2"); - - const bootstrapShard = requiredWorkflowStep( - prWorkflow.jobs["cli-test-shards"], - "Run E2E support shard (bootstrap)", - ); - expect(bootstrapShard.if).toBe( - "${{ steps.trusted-shard-capabilities.outputs.e2e-support != 'true' }}", - ); - expect(bootstrapShard.run).toContain("--project e2e-support"); - expect(bootstrapShard.run).toContain( - '--shard="${E2E_SUPPORT_SHARD}/${E2E_SUPPORT_SHARD_COUNT}"', - ); - - for (const workflow of [prWorkflow, mainWorkflow]) { - expect(workflow.jobs["e2e-support"]).toBeUndefined(); - expect(workflow.jobs["test-e2e-ollama-proxy"]).toBeUndefined(); - expect(workflow.jobs.checks.needs).not.toContain("e2e-support"); - expect(workflow.jobs.checks.needs).not.toContain("test-e2e-ollama-proxy"); - } - - expect(stepRuns(sharedActions.staticChecks).join("\n")).not.toContain( - "skills-frontmatter.test.ts", - ); - const trustedRatchetDependencies = requiredStep( - sharedActions.staticChecks, - "Install base-trusted createRequire verifier dependencies", - ); - const trustedRatchet = requiredStep( - sharedActions.staticChecks, - "Enforce base-trusted createRequire allowlist ratchet", - ); - expect(trustedRatchetDependencies.run).toBe( - 'npm ci --ignore-scripts --no-audit --no-fund --prefix "$GITHUB_ACTION_PATH"', - ); - expect(trustedRatchet.run).toBe( - 'node --experimental-strip-types "$GITHUB_ACTION_PATH/create-require-ratchet.mts"', - ); - expect(stepRuns(sharedActions.staticChecks)).not.toContain( - 'npx tsx "$GITHUB_ACTION_PATH/create-require-ratchet.mts"', - ); - expect( - requiredStepIndex( - sharedActions.staticChecks, - "Install base-trusted createRequire verifier dependencies", - ), - ).toBeLessThan( - requiredStepIndex( - sharedActions.staticChecks, - "Enforce base-trusted createRequire allowlist ratchet", - ), - ); - expect( - requiredStepIndex( - sharedActions.staticChecks, - "Enforce base-trusted createRequire allowlist ratchet", - ), - ).toBeLessThan(requiredStepIndex(sharedActions.staticChecks, "Install dependencies")); - - const ratchetPackage = JSON.parse( - readFileSync(".github/actions/ci-static-checks/package.json", "utf8"), - ) as { dependencies?: Record }; - const ratchetLock = JSON.parse( - readFileSync(".github/actions/ci-static-checks/package-lock.json", "utf8"), - ) as { - packages?: Record; - }; - const ratchetRuntime = readFileSync( - ".github/actions/ci-static-checks/create-require-ratchet.mts", - "utf8", - ); - expect(ratchetPackage.dependencies).toEqual({ typescript: "6.0.3" }); - expect(ratchetLock.packages?.["node_modules/typescript"]?.version).toBe("6.0.3"); - expect(ratchetLock.packages?.["node_modules/typescript"]?.integrity).toMatch(/^sha512-/); - expect(ratchetRuntime).toContain( - 'import ts from "./node_modules/typescript/lib/typescript.js";', - ); - expect(ratchetRuntime).not.toMatch(/from ["']typescript["']/); - }); - - // source-shape-contract: security -- Downloaded CI tooling must use a committed digest rather than upstream metadata - it("pins downloaded CI tooling to reviewed integrity", () => { - const docsRuns = stepRuns(prWorkflow.jobs["docs-only-checks"]).join("\n"); - for (const runs of [stepRuns(sharedActions.staticChecks).join("\n"), docsRuns]) { - expect(runs).toContain("6bf226944684f56c84dd014e8b979d27425c0148f61b3bd99bcc6f39e9dc5a47"); - expect(runs).not.toMatch(/HADOLINT_URL.*sha256|EXPECTED=\$\(curl/); - } - expect(docsRuns.indexOf("HADOLINT_SHA256")).toBeLessThan(docsRuns.indexOf("prek run")); - }); - it("validates CLI shard inputs before using them in shell commands", () => { const shardValidationStep = requiredStep( sharedActions.cliCoverageShard, @@ -1249,44 +514,6 @@ describe("pull request and main workflow contracts", () => { } }); - // source-shape-contract: security -- Growth-budget changes must inspect trusted GitHub data without fetching PR-authored URLs - it("keeps the trusted test-size guard closed around budget policy changes", () => { - const growthGuardrails = readYaml( - ".github/workflows/codebase-growth-guardrails.yaml", - ); - const guardJob = growthGuardrails.jobs["codebase-growth-guardrails"]; - const guardRun = stepRuns(guardJob).join("\n"); - const guardEnv = JSON.stringify((guardJob.steps ?? []).map((step) => step.env ?? {})); - expect(guardEnv).toContain("HEAD_REPO"); - expect(guardRun).not.toContain(".raw_url"); - expect(guardRun).not.toContain("node <<'NODE'"); - expect(guardRun).toContain("tools/growth-guardrails/test-size-budget.mts"); - expect(guardRun).toContain("tools/growth-guardrails/test-conditionals.mts"); - }); - - // source-shape-contract: security -- Coverage publication must exclude fork-authored reports and pin the publishing action - it("publishes coverage only from same-repository code (#6692)", () => { - const sameRepositoryGuard = - "${{ always() && (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository) }}"; - const uploadAction = "actions/upload-code-coverage@abb5995db9e0199b0e2bb9dbd136fce4cb1ec4d3"; - const reports = [ - { - action: sharedActions.cliCoverageMerge, - uploadStep: "Upload CLI coverage report", - }, - { - action: sharedActions.pluginCoverage, - uploadStep: "Upload plugin coverage report", - }, - ] as const; - - for (const report of reports) { - const uploadStep = requiredStep(report.action, report.uploadStep); - expect(uploadStep.if).toBe(sameRepositoryGuard); - expect(uploadStep.uses).toBe(uploadAction); - } - }); - it("links every failed CLI shard and falls back safely when job metadata is unavailable", () => { const runUrl = "https://github.com/NVIDIA/NemoClaw/actions/runs/123"; const failedShards = workflowJobListing([ @@ -1449,48 +676,4 @@ describe("pull request and main workflow contracts", () => { ); expect(oversizedFailure.stdout).not.toContain("actions/runs/123/job/"); }); - - // source-shape-contract: security -- CI dependency installs must never execute package lifecycle scripts from fetched code - it("does not run npm lifecycle scripts during CI dependency installs", () => { - for (const [actionName, action] of Object.entries(sharedActions)) { - const installRuns = stepRuns(action).filter((run) => run.includes("npm install")); - - expect(installRuns.length, `${actionName} install steps`).toBeGreaterThan(0); - for (const run of installRuns) { - for (const line of run.split("\n").map((candidate) => candidate.trim())) { - if (line.includes("npm install")) { - expect(line, `${actionName} install command`).toContain("--ignore-scripts"); - } - } - } - } - - const docsOnlyInstall = stepRuns(prWorkflow.jobs["docs-only-checks"]).find((run) => - run.includes("npm install"), - ); - expect(docsOnlyInstall).toBe("npm install --ignore-scripts"); - const installerBootstrapInstall = stepRuns(prWorkflow.jobs["installer-integration"]).find( - (run) => run.includes("npm install"), - ); - expect(installerBootstrapInstall).toContain("npm install --ignore-scripts"); - expect(installerBootstrapInstall).toContain("cd nemoclaw && npm install --ignore-scripts"); - }); - - // source-shape-contract: security -- Workflow checkouts must not leave write-capable credentials available to later steps - it("does not persist checkout credentials in PR or main jobs", () => { - for (const [workflowName, workflow] of [ - ["pull_request", prWorkflow], - ["main", mainWorkflow], - ] as const) { - for (const [jobName, job] of Object.entries(workflow.jobs)) { - for (const step of job.steps ?? []) { - if (!step.uses?.startsWith("actions/checkout@")) { - continue; - } - - expect(step.with?.["persist-credentials"], `${workflowName} ${jobName}`).toBe(false); - } - } - } - }); }); diff --git a/test/publish-base-image-manifest.test.ts b/test/publish-base-image-manifest.test.ts new file mode 100644 index 00000000000..798b5cae93c --- /dev/null +++ b/test/publish-base-image-manifest.test.ts @@ -0,0 +1,161 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { chmodSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join, resolve } from "node:path"; +import { spawnSync } from "node:child_process"; + +import { afterEach, describe, expect, it } from "vitest"; + +const SHA_AMD64 = "a".repeat(64); +const SHA_ARM64 = "b".repeat(64); +const SHA_CANDIDATE = `sha256:${"c".repeat(64)}`; +const SHA_PUBLISHED = `sha256:${"d".repeat(64)}`; +const helper = resolve( + import.meta.dirname, + "../.github/actions/publish-base-image-manifest/publish.sh", +); +const temporaryRoots: string[] = []; + +afterEach(() => { + for (const root of temporaryRoots.splice(0)) rmSync(root, { force: true, recursive: true }); +}); + +function executable(file: string, content: string): void { + writeFileSync(file, content); + chmodSync(file, 0o755); +} + +function runManifest({ + digestFiles, + wrongPlatform = false, + publicationMismatch = false, +}: { + digestFiles: string[]; + wrongPlatform?: boolean; + publicationMismatch?: boolean; +}) { + const root = mkdtempSync(join(tmpdir(), "nemoclaw-base-manifest-")); + temporaryRoots.push(root); + const runnerTemp = join(root, "runner"); + const digests = join(runnerTemp, "digests"); + const checks = join(root, "scripts", "checks"); + const bin = join(root, "bin"); + mkdirSync(digests, { recursive: true }); + mkdirSync(checks, { recursive: true }); + mkdirSync(bin, { recursive: true }); + for (const file of digestFiles) writeFileSync(join(digests, file), ""); + + executable( + join(checks, "retry-docker-imagetools-inspect.sh"), + `#!/bin/bash +set -euo pipefail +if [[ " $* " == *" --raw "* ]]; then + printf '%s\n' '{"manifests":[{"platform":{"os":"linux","architecture":"amd64"}},{"platform":{"os":"linux","architecture":"arm64"}}]}' + exit 0 +fi +if [ "$WRONG_PLATFORM" = true ]; then + printf '%s\n' linux/arm64 +elif [[ "$1" == *"${SHA_AMD64}"* ]]; then + printf '%s\n' linux/amd64 +else + printf '%s\n' linux/arm64 +fi +`, + ); + executable( + join(checks, "validate-managed-base-index.sh"), + `#!/bin/bash +printf '{"linux/amd64":"sha256:%s","linux/arm64":"sha256:%s"}\n' '${SHA_AMD64}' '${SHA_ARM64}' +`, + ); + executable( + join(root, "scripts", "export-managed-base-image-contract.sh"), + `#!/bin/bash +set -euo pipefail +output="$9" +mkdir -p "$(dirname "$output")" +printf '{}\n' > "$output" +`, + ); + executable( + join(bin, "docker"), + `#!/bin/bash +set -euo pipefail +metadata="" +previous="" +for argument in "$@"; do + if [ "$previous" = "--metadata-file" ]; then metadata="$argument"; fi + previous="$argument" +done +[ -n "$metadata" ] +if [[ "$metadata" == *publication* ]] && [ "$PUBLICATION_MISMATCH" = true ]; then + digest='${SHA_PUBLISHED}' +else + digest='${SHA_CANDIDATE}' +fi +mkdir -p "$(dirname "$metadata")" +printf '{"containerimage.descriptor":{"digest":"%s"}}\n' "$digest" > "$metadata" +`, + ); + + const output = join(root, "github-output"); + return spawnSync("bash", [helper], { + cwd: root, + encoding: "utf8", + env: { + ...process.env, + AGENT: "openclaw", + DISPLAY_NAME: "OpenClaw", + GITHUB_OUTPUT: output, + GITHUB_RUN_ATTEMPT: "1", + GITHUB_RUN_ID: "1", + GITHUB_SHA: "e".repeat(40), + IMAGE: "ghcr.io/nvidia/nemoclaw/sandbox-base", + PATH: `${bin}:${process.env.PATH || ""}`, + PUBLICATION_MISMATCH: String(publicationMismatch), + RUNNER_TEMP: runnerTemp, + TAGS: "ghcr.io/nvidia/nemoclaw/sandbox-base:test", + WRONG_PLATFORM: String(wrongPlatform), + }, + }); +} + +describe("base-image manifest publication", () => { + it("rejects a malformed platform digest artifact", () => { + const result = runManifest({ digestFiles: ["amd64-invalid", `arm64-${SHA_ARM64}`] }); + + expect(result.status).not.toBe(0); + expect(result.stderr).toContain("invalid platform digest artifact: amd64-invalid"); + }); + + it("rejects duplicate platform digest artifacts", () => { + const result = runManifest({ digestFiles: [`amd64-${SHA_AMD64}`, `amd64-${SHA_ARM64}`] }); + + expect(result.status).not.toBe(0); + expect(result.stderr).toContain("duplicate platform digest for linux/amd64"); + }); + + it("rejects a digest that resolves to the wrong platform", () => { + const result = runManifest({ + digestFiles: [`amd64-${SHA_AMD64}`, `arm64-${SHA_ARM64}`], + wrongPlatform: true, + }); + + expect(result.status).not.toBe(0); + expect(result.stderr).toContain("digest for linux/amd64 resolves to linux/arm64"); + }); + + it("rejects a published digest that differs from the validated candidate", () => { + const result = runManifest({ + digestFiles: [`amd64-${SHA_AMD64}`, `arm64-${SHA_ARM64}`], + publicationMismatch: true, + }); + + expect(result.status).not.toBe(0); + expect(result.stderr).toContain( + "published OpenClaw base digest differs from the validated candidate", + ); + }); +}); diff --git a/test/regression-e2e-workflow.test.ts b/test/regression-e2e-workflow.test.ts deleted file mode 100644 index 4fbb71e603d..00000000000 --- a/test/regression-e2e-workflow.test.ts +++ /dev/null @@ -1,165 +0,0 @@ -// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. -// SPDX-License-Identifier: Apache-2.0 - -import { describe, expect, it } from "vitest"; - -import { - PREPARE_E2E_ACTION, - PREPARE_E2E_STEP, -} from "../tools/e2e/prepare-e2e-workflow-boundary.mts"; -import { readYaml, type WorkflowStep } from "./helpers/e2e-workflow-contract"; - -type RegressionWorkflow = { - on?: { - workflow_dispatch?: { - inputs?: { - jobs?: { - description?: string; - }; - }; - }; - }; - permissions?: Record; - jobs?: Record< - string, - { - permissions?: Record; - steps?: WorkflowStep[]; - "timeout-minutes"?: number; - uses?: string; - } - >; -}; - -const FULL_SHA_ACTION = /@[0-9a-f]{40}$/i; - -function preparedVitestJobs(workflow: RegressionWorkflow) { - return Object.entries(workflow.jobs ?? {}).filter(([, job]) => { - const steps = job.steps ?? []; - const invokesVitest = steps.some((step) => /\bvitest\s+run\b/.test(step.run ?? "")); - const usesDirectSetup = steps.some((step) => step.name === "Setup Node"); - return invokesVitest && !usesDirectSetup; - }); -} - -describe("Regression E2E workflow contract", () => { - const workflow = readYaml(".github/workflows/regression-e2e.yaml"); - - // source-shape-contract: compatibility -- Keeps the executable WhatsApp regression on the supported Vitest live runner - it("runs WhatsApp compact QR through Vitest instead of the retired shell script", () => { - const job = workflow.jobs?.["whatsapp-qr-compact-e2e"]; - const runText = (job?.steps ?? []).map((step) => step.run ?? "").join("\n"); - - expect(runText).toContain("test/e2e/live/whatsapp-qr-compact.test.ts"); - expect(runText).toContain("npx vitest run --project e2e-live"); - }); - - // source-shape-contract: security -- Preserves the public NVIDIA credential boundary for Model Router regression execution - it("stages the public NVIDIA key for the Model Router's NVIDIA credential", () => { - const branchValidationCallers = Object.values(workflow.jobs ?? {}).filter( - (job) => job.uses === "./.github/workflows/e2e-branch-validation.yaml", - ); - const job = workflow.jobs?.["model-router-provider-routed-inference-e2e"]; - const runStep = job?.steps?.find( - (step) => step.name === "Run Model Router provider-routed inference E2E test", - ); - expect(runStep?.env?.NVIDIA_API_KEY).toBe("${{ secrets.NVIDIA_API_KEY }}"); - expect(runStep?.env?.NVIDIA_INFERENCE_API_KEY).toBeUndefined(); - expect(branchValidationCallers).toHaveLength(0); - expect(workflow.permissions).toEqual({ - actions: "read", - checks: "write", - contents: "read", - "pull-requests": "write", - }); - }); - - // source-shape-contract: security -- Every discovered non-hermetic Vitest job must use immutable credential-free preparation - it("prepares every discovered non-hermetic Vitest job before execution (#6692)", () => { - const preparedJobs = preparedVitestJobs(workflow); - - expect(preparedJobs.length).toBeGreaterThan(0); - for (const [jobName, job] of preparedJobs) { - const steps = job?.steps ?? []; - const checkoutIndex = steps.findIndex((step) => step.uses?.startsWith("actions/checkout@")); - const prepareIndex = steps.findIndex((step) => step.name === PREPARE_E2E_STEP); - const runIndex = steps.findIndex((step) => /\bvitest\s+run\b/.test(step.run ?? "")); - const checkout = steps[checkoutIndex]; - const prepare = steps[prepareIndex]; - - expect(job?.permissions, jobName).toEqual({ contents: "read" }); - expect(checkout?.uses, jobName).toMatch(FULL_SHA_ACTION); - expect(checkout?.with?.["persist-credentials"], jobName).toBe(false); - expect(prepare?.uses, jobName).toBe(PREPARE_E2E_ACTION); - expect( - prepare?.with === undefined || - JSON.stringify(prepare.with) === JSON.stringify({ "build-cli": "false" }), - `${jobName} prepare inputs`, - ).toBe(true); - expect(prepare?.env, jobName).toBeUndefined(); - expect(prepareIndex, jobName).toBeGreaterThan(checkoutIndex); - expect(runIndex, jobName).toBeGreaterThan(prepareIndex); - expect( - steps.filter((step) => step.uses === PREPARE_E2E_ACTION), - jobName, - ).toHaveLength(1); - expect( - steps.map((step) => step.name), - jobName, - ).not.toContain("Setup Node"); - expect( - steps.map((step) => step.name), - jobName, - ).not.toContain("Install root dependencies"); - expect( - steps.map((step) => step.name), - jobName, - ).not.toContain("Build CLI"); - } - }); - - // source-shape-contract: security -- Keeps the custom-plugin EXDEV regression immutable and free of repository secrets - it("runs the OpenClaw custom-plugin lifecycle and EXDEV guard in a secret-free lane", () => { - const releaseJob = workflow.jobs?.["openclaw-plugin-runtime-exdev-release-e2e"]; - const job = workflow.jobs?.["openclaw-plugin-runtime-exdev-e2e"]; - const steps = job?.steps ?? []; - const runText = steps.map((step) => step.run ?? "").join("\n"); - const checkoutStep = steps.find((step) => - String(step.uses ?? "").startsWith("actions/checkout@"), - ); - const setupNodeStep = steps.find((step) => step.name === "Setup Node"); - const runVitestStep = steps.find( - (step) => - step.name === "Run OpenClaw custom-plugin lifecycle and runtime-deps EXDEV Vitest test", - ); - const serializedJob = JSON.stringify(job); - - expect(job?.permissions).toEqual({ contents: "read" }); - expect(job?.["timeout-minutes"]).toBe(105); - expect(checkoutStep?.uses).toMatch(FULL_SHA_ACTION); - expect(checkoutStep?.with?.["persist-credentials"]).toBe(false); - expect(setupNodeStep?.uses).toMatch(FULL_SHA_ACTION); - expect(runVitestStep?.env?.NEMOCLAW_RUN_LIVE_E2E).toBe("1"); - expect(serializedJob).not.toContain("${{ secrets."); - expect(serializedJob).not.toMatch(/"secrets"\s*:\s*"inherit"/); - for (const step of steps) { - expect( - step.env?.NVIDIA_INFERENCE_API_KEY, - step.name ?? step.uses ?? "", - ).toBeUndefined(); - } - - expect(runText).toContain("test/e2e/live/openclaw-plugin-runtime-exdev.test.ts"); - expect(runText).toContain("-t current-lifecycle"); - expect(runText).toContain("npx vitest run --project e2e-live"); - expect(runText).toContain("npm ci --ignore-scripts"); - expect(runText).toContain("npm run build:cli"); - - const releaseRunText = (releaseJob?.steps ?? []).map((step) => step.run ?? "").join("\n"); - expect(releaseJob?.permissions).toEqual({ contents: "read" }); - expect(releaseJob?.["timeout-minutes"]).toBe(55); - expect(JSON.stringify(releaseJob)).not.toContain("${{ secrets."); - expect(releaseRunText).toContain("test/e2e/live/openclaw-plugin-runtime-exdev.test.ts"); - expect(releaseRunText).toContain("-t release-baseline"); - }); -}); diff --git a/test/release-lkg-brev-image.test.ts b/test/release-lkg-brev-image.test.ts index 9a23835476a..e26a449ff17 100644 --- a/test/release-lkg-brev-image.test.ts +++ b/test/release-lkg-brev-image.test.ts @@ -284,30 +284,4 @@ describe("LKG production image dispatch", () => { expect(summary).toContain("Downstream run: `unavailable`"); expect(summary).not.toContain("actions/runs/"); }); - - // source-shape-contract: security -- The secret-bearing LKG trigger must stay canonical, deletion-safe, read-only, and immutable - it("keeps LKG dispatch inside the trusted secret boundary (#6772)", () => { - const workflow = readYaml(".github/workflows/release-lkg-brev-image.yaml"); - const job = workflow.jobs["dispatch-production-image"]; - const checkout = job.steps?.find((step) => step.name === "Check out LKG target"); - const dispatch = job.steps?.find((step) => step.name === "Dispatch production image build"); - - expect(workflow.on?.push?.tags).toEqual(["lkg"]); - expect(workflow.permissions).toEqual({ contents: "read" }); - expect(job.if).toBe( - "${{ github.repository == 'NVIDIA/NemoClaw' && github.event.deleted == false }}", - ); - expect(job["timeout-minutes"]).toBe(5); - expect(checkout?.uses).toMatch(/^actions\/checkout@[0-9a-f]{40}$/u); - expect(checkout?.with).toEqual({ - ref: "${{ github.sha }}", - "fetch-depth": 0, - "persist-credentials": false, - }); - expect(dispatch?.env).toEqual({ - LKG_SHA: "${{ github.sha }}", - NEMOCLAW_IMAGE_DISPATCH_TOKEN: "${{ secrets.NEMOCLAW_IMAGE_DISPATCH_TOKEN }}", - }); - expect(dispatch?.run).toBe("scripts/release-lkg-brev-image.sh"); - }); }); diff --git a/test/reviewed-npm-audit-workflow.test.ts b/test/reviewed-npm-audit-workflow.test.ts index 7dd961423f7..b2352442b1a 100644 --- a/test/reviewed-npm-audit-workflow.test.ts +++ b/test/reviewed-npm-audit-workflow.test.ts @@ -137,132 +137,6 @@ describe("trusted reviewed npm audit workflow (#5896)", () => { ); }); - // source-shape-contract: security -- PR dependency audit code must come from the base SHA or the one-time signed bootstrap - it("runs PR audits from trusted code and keeps the main audit on the checked-in action", () => { - const pr = readYaml(".github/workflows/pr.yaml"); - const main = readYaml(".github/workflows/main.yaml"); - const prJob = pr.jobs["reviewed-npm-audit"]; - const mainJob = main.jobs["reviewed-npm-audit"]; - - const trustedCheckout = requiredStep(prJob, "Checkout trusted reviewed npm audit"); - expect(trustedCheckout.with).toMatchObject({ - ref: "${{ github.event.pull_request.base.sha }}", - path: ".trusted-reviewed-npm-audit", - "persist-credentials": false, - "sparse-checkout-cone-mode": false, - }); - const sparseCheckout = String(trustedCheckout.with?.["sparse-checkout"]); - expect(sparseCheckout).toContain(".github/actions/ci-reviewed-npm-audit"); - expect(sparseCheckout).toContain("ci/npm-audit-exceptions.json"); - expect(sparseCheckout).toContain("ci/reviewed-npm-audit.json"); - expect(sparseCheckout).toContain("scripts/audit-reviewed-npm-graph.mts"); - expect(sparseCheckout).toContain("scripts/lib/openclaw-npm-remediation.mts"); - expect(sparseCheckout).toContain("scripts/lib/reviewed-npm-archive.mts"); - expect(sparseCheckout).toContain("scripts/lib/reviewed-npm-audit.mts"); - - const detection = requiredStep(prJob, "Detect trusted reviewed npm audit schema"); - expect(detection.id).toBe("trusted-reviewed-npm-audit"); - expect(detection.run).toContain("resolveTrustedAuditConfigPath(TRUSTED_REPO_ROOT)"); - expect(detection.run).toContain(".trusted-reviewed-npm-audit/ci/npm-audit-exceptions.json"); - expect(detection.run).toContain(".trusted-reviewed-npm-audit/ci/reviewed-npm-audit.json"); - expect(detection.run).toContain( - ".trusted-reviewed-npm-audit/scripts/lib/openclaw-npm-remediation.mts", - ); - expect(detection.run).toContain( - ".trusted-reviewed-npm-audit/scripts/lib/reviewed-npm-audit.mts", - ); - - const bootstrap = requiredStep(prJob, "Checkout pinned bootstrap reviewed npm audit"); - expect(bootstrap.if).toBe(BOOTSTRAP_IF); - expect(bootstrap.with).toMatchObject({ - repository: "HOYALIM/NemoClaw", - ref: BOOTSTRAP_SHA, - path: ".trusted-reviewed-npm-audit-bootstrap", - "persist-credentials": false, - }); - const bootstrapSparseCheckout = String(bootstrap.with?.["sparse-checkout"]); - expect(bootstrapSparseCheckout).toContain("ci/npm-audit-exceptions.json"); - expect(bootstrapSparseCheckout).toContain("scripts/lib/openclaw-npm-remediation.mts"); - expect(bootstrapSparseCheckout).toContain("scripts/lib/reviewed-npm-audit.mts"); - const rejectUnavailable = requiredStep(prJob, "Reject unavailable trusted reviewed npm audit"); - expect(rejectUnavailable.if).toBe(REJECT_UNAVAILABLE_IF); - expect(rejectUnavailable.run).toContain("exit 1"); - expect(requiredStep(prJob, "Audit reviewed production npm graphs")).toMatchObject({ - if: "${{ steps.trusted-reviewed-npm-audit.outputs.available == 'true' }}", - uses: "./.trusted-reviewed-npm-audit/.github/actions/ci-reviewed-npm-audit", - with: { - "target-root": "${{ github.workspace }}", - "report-dir": "artifacts/reviewed-npm-audit", - }, - }); - expect( - requiredStep(prJob, "Audit reviewed production npm graphs (pinned bootstrap)"), - ).toMatchObject({ - if: BOOTSTRAP_IF, - uses: "./.trusted-reviewed-npm-audit-bootstrap/.github/actions/ci-reviewed-npm-audit", - }); - expect(requiredStep(mainJob, "Audit reviewed production npm graphs")).toMatchObject({ - uses: "./.github/actions/ci-reviewed-npm-audit", - with: { - "target-root": "${{ github.workspace }}", - "report-dir": "artifacts/reviewed-npm-audit", - }, - }); - }); - - // source-shape-contract: security -- The trusted composite action must execute only its bundled driver while treating the PR checkout as explicit data - it("executes the trusted driver and helper against explicit target inputs", () => { - const action = fs.readFileSync( - path.join(REPO_ROOT, ".github", "actions", "ci-reviewed-npm-audit", "action.yaml"), - "utf8", - ); - const driver = fs.readFileSync( - path.join(REPO_ROOT, "scripts", "audit-reviewed-npm-graph.mts"), - "utf8", - ); - const helper = fs.readFileSync( - path.join(REPO_ROOT, "scripts", "lib", "reviewed-npm-audit.mts"), - "utf8", - ); - const npmBootstrap = fs.readFileSync( - path.join( - REPO_ROOT, - ".github", - "actions", - "ci-reviewed-npm-audit", - "verify-and-install-npm.sh", - ), - "utf8", - ); - - expect(action).toContain('node-version: "22.23.1"'); - expect(action).toContain('NEMOCLAW_REVIEWED_NPM_VERSION: "10.9.4"'); - expect(action).toContain("NEMOCLAW_REVIEWED_NPM_INTEGRITY: >-"); - expect(action).toContain( - "sha512-OnUG836FwboQIbqtefDNlyR0gTHzIfwRfE3DuiNewBvnMnWEpB0VEXwBlFVgqpNzIgYo/MHh3d2Hel/pszapAA==", - ); - expect(action).toContain("run: '\"$GITHUB_ACTION_PATH/verify-and-install-npm.sh\"'"); - expect(action).not.toContain("npm install --global npm@10.9.4"); - expect(npmBootstrap).toContain('npm pack "npm@$version"'); - expect(npmBootstrap).toContain('crypto.createHash("sha512")'); - expect(npmBootstrap).toContain('if [ "$actual_integrity" != "$expected_integrity" ]'); - expect(npmBootstrap).toContain('npm install --global "$archive"'); - expect( - npmBootstrap.indexOf('if [ "$actual_integrity" != "$expected_integrity" ]'), - ).toBeLessThan(npmBootstrap.indexOf('npm install --global "$archive"')); - expect(npmBootstrap).toContain("--ignore-scripts --no-audit --no-fund --offline"); - expect(action).toContain("NEMOCLAW_REVIEWED_NPM_AUDIT_TARGET_ROOT"); - expect(action).toContain("NEMOCLAW_REVIEWED_NPM_AUDIT_REPORT_DIR"); - expect(action).toContain( - 'node --experimental-strip-types "$GITHUB_ACTION_PATH/../../../scripts/audit-reviewed-npm-graph.mts"', - ); - expect(action).not.toContain("run: node --experimental-strip-types scripts/"); - expect(driver).toContain("resolveTrustedAuditConfigPath(TRUSTED_REPO_ROOT)"); - expect(helper).toContain("const NPM_AUDIT_ATTEMPT_TIMEOUT_MS = 45_000"); - expect(helper).toContain("timeout: NPM_AUDIT_ATTEMPT_TIMEOUT_MS"); - expect(driver).not.toContain('resolveTargetPath(\n "ci/reviewed-npm-audit.json"'); - }); - it("rejects a mismatched npm bootstrap archive before installation (#8253)", () => { const root = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-reviewed-npm-bootstrap-")); const bin = path.join(root, "bin"); diff --git a/test/sandbox-base-image-layout.test.ts b/test/sandbox-base-image-layout.test.ts deleted file mode 100644 index 69144b88621..00000000000 --- a/test/sandbox-base-image-layout.test.ts +++ /dev/null @@ -1,25 +0,0 @@ -// 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"; - -const repoRoot = path.resolve(import.meta.dirname, ".."); -const dockerfile = fs.readFileSync(path.join(repoRoot, "Dockerfile.base"), "utf8"); - -function completedStage(source: string): string { - const stages = source.split(/(?=^FROM )/gmu).filter((stage) => stage.startsWith("FROM ")); - return stages.at(-1) ?? ""; -} - -describe("sandbox base image layout", () => { - it("keeps the published image within the established layer budget", () => { - const finalStage = completedStage(dockerfile); - const layerInstructions = finalStage.match(/^(?:ADD|COPY|RUN)\b/gmu) ?? []; - - expect(finalStage).toContain("FROM node:22-trixie-slim@"); - expect(layerInstructions).toHaveLength(26); - }); -}); diff --git a/test/vitest-watch-triggers.test.ts b/test/vitest-watch-triggers.test.ts index 6243cfc9ded..f451c048c42 100644 --- a/test/vitest-watch-triggers.test.ts +++ b/test/vitest-watch-triggers.test.ts @@ -211,7 +211,6 @@ describe("Vitest opaque-input watch triggers", () => { expect(triggeredBy("scripts/unrelated.py")).toEqual([]); expect(triggeredBy("test/e2e/lib/unrelated.sh")).toEqual([]); expect(triggeredBy("agents/hermes/hermes-wrapper.py")).toEqual([]); - expect(triggeredBy(".github/workflows/regression-e2e.yaml")).toEqual([]); }); it("normalizes Windows-style paths before matching (#6692)", () => { diff --git a/test/wechat-runtime-audit-workflow.test.ts b/test/wechat-runtime-audit-workflow.test.ts index 5dd025950db..b05b0ca1229 100644 --- a/test/wechat-runtime-audit-workflow.test.ts +++ b/test/wechat-runtime-audit-workflow.test.ts @@ -168,70 +168,6 @@ function requiredStep(job: WorkflowJob, name: string): WorkflowStep { } describe("WeChat runtime audit and install-cache gates (#5896)", () => { - // source-shape-contract: security -- Trusted PR and main workflows must enforce the reviewed WeChat runtime audit boundary - it("makes the trusted audit required in PR and main workflows", () => { - const pr = readYaml(".github/workflows/pr.yaml"); - const main = readYaml(".github/workflows/main.yaml"); - const prJob = pr.jobs["wechat-runtime-audit"]; - const mainJob = main.jobs["wechat-runtime-audit"]; - - const trustedCheckout = requiredStep(prJob, "Checkout trusted WeChat runtime audit"); - expect(trustedCheckout.with).toMatchObject({ - ref: "${{ github.event.pull_request.base.sha }}", - path: ".trusted-wechat-audit", - "persist-credentials": false, - "sparse-checkout-cone-mode": false, - }); - expect(String(trustedCheckout.with?.["sparse-checkout"])).toContain( - ".github/actions/ci-wechat-runtime-audit", - ); - - const bootstrapCheckout = requiredStep(prJob, "Checkout pinned bootstrap WeChat runtime audit"); - expect(bootstrapCheckout.if).toBe( - "${{ steps.trusted-wechat-audit.outputs.available != 'true' && github.event.pull_request.number == 6739 && github.event.pull_request.head.repo.full_name == 'HOYALIM/NemoClaw' }}", - ); - expect(bootstrapCheckout.with).toMatchObject({ - repository: "HOYALIM/NemoClaw", - ref: "0d2256d71d5bbba3bcaaaa4d01714fa56f22d1e2", - path: ".trusted-wechat-audit-bootstrap", - "persist-credentials": false, - }); - expect(requiredStep(prJob, "Audit locked WeChat runtime graph").uses).toBe( - "./.trusted-wechat-audit/.github/actions/ci-wechat-runtime-audit", - ); - expect(requiredStep(prJob, "Audit locked WeChat runtime graph (pinned bootstrap)").uses).toBe( - "./.trusted-wechat-audit-bootstrap/.github/actions/ci-wechat-runtime-audit", - ); - expect(requiredStep(mainJob, "Audit locked WeChat runtime graph").uses).toBe( - "./.github/actions/ci-wechat-runtime-audit", - ); - - for (const [workflowName, workflow, job] of [ - ["pr", pr, prJob], - ["main", main, mainJob], - ] as const) { - const upload = requiredStep(job, "Upload WeChat runtime audit evidence"); - expect(upload.if).toBe("${{ always() }}"); - expect(upload.uses).toBe("actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a"); - expect(upload.with).toMatchObject({ - path: "artifacts/wechat-runtime-audit", - "if-no-files-found": "error", - }); - - expect(workflow.jobs.checks.needs).toContain("wechat-runtime-audit"); - const gate = requiredStep( - workflow.jobs.checks, - workflowName === "pr" ? "Verify required PR checks" : "Verify required main checks", - ); - expect(gate.env).toMatchObject({ - WECHAT_RUNTIME_AUDIT_RESULT: "${{ needs['wechat-runtime-audit'].result }}", - }); - expect(gate.run).toContain( - 'require_success "wechat-runtime-audit" "$WECHAT_RUNTIME_AUDIT_RESULT"', - ); - } - }); - it("audits the installed graph and exercises the exact archive through a copied cache", () => { const script = fs.readFileSync(auditScript, "utf8"); for (const fragment of [ @@ -258,17 +194,6 @@ describe("WeChat runtime audit and install-cache gates (#5896)", () => { ]) { expect(script).toContain(fragment); } - - const action = fs.readFileSync( - path.join(repoRoot, ".github", "actions", "ci-wechat-runtime-audit", "action.yaml"), - "utf8", - ); - expect(action).toContain('node-version: "22.19.0"'); - expect(action).toContain('cd "$RUNNER_TEMP"'); - expect(action).toContain("npm install --global npm@10.9.4"); - expect(action).toContain("--userconfig /dev/null"); - expect(action).toContain("--registry https://registry.npmjs.org/"); - expect(action).toContain('run: bash "$GITHUB_ACTION_PATH/audit.sh"'); }); it("rejects a target-controlled npm registry override", () => { diff --git a/tools/e2e/base-image-publication.mts b/tools/e2e/base-image-publication.mts index 0bda1c48742..7da5f0b8690 100644 --- a/tools/e2e/base-image-publication.mts +++ b/tools/e2e/base-image-publication.mts @@ -23,9 +23,14 @@ const MAX_RETRY_DELAY_MS = 10_000; const SHA_PATTERN = /^[0-9a-f]{40}$/u; const SAFE_PATH_PATTERN = /^[A-Za-z0-9._/-]+$/u; const REVIEWED_PATH_GLOBS = new Map([ + [".github/actions/ci-reviewed-npm-audit/**", /^[.]github\/actions\/ci-reviewed-npm-audit\/.+$/u], [ - ".github/actions/ci-reviewed-npm-audit/**", - /^[.]github\/actions\/ci-reviewed-npm-audit\/.+$/u, + ".github/actions/build-base-image-platform/**", + /^[.]github\/actions\/build-base-image-platform\/.+$/u, + ], + [ + ".github/actions/publish-base-image-manifest/**", + /^[.]github\/actions\/publish-base-image-manifest\/.+$/u, ], ["agents/**", /^agents\/.+$/u], ["nemoclaw/**", /^nemoclaw\/.+$/u], @@ -248,13 +253,11 @@ function defaultGit(args: string[]): string { }).trim(); } -export function expandBaseImagePushPaths( - expectedSha: string, - paths: readonly string[], -): string[] { +export function expandBaseImagePushPaths(expectedSha: string, paths: readonly string[]): string[] { sha(expectedSha, "expected SHA"); - return [...new Set(paths.map((path) => (REVIEWED_PATH_GLOBS.has(path) ? `:(glob)${path}` : path)))] - .sort(); + return [ + ...new Set(paths.map((path) => (REVIEWED_PATH_GLOBS.has(path) ? `:(glob)${path}` : path))), + ].sort(); } export function resolveFirstParentHistory( diff --git a/tools/e2e/cli-artifact-workflow-boundary.mts b/tools/e2e/cli-artifact-workflow-boundary.mts index 431dca19451..4d27a40f2d0 100644 --- a/tools/e2e/cli-artifact-workflow-boundary.mts +++ b/tools/e2e/cli-artifact-workflow-boundary.mts @@ -13,13 +13,13 @@ import { PREPARE_E2E_NO_BUILD_JOBS, PREPARE_E2E_TRUSTED_BUILD_JOBS, } from "./prepare-e2e-workflow-boundary.mts"; +import { E2E_ACTION_PROVENANCE } from "./workflow-boundary-policy.mts"; export const CLI_ARTIFACT_DOWNLOAD_ACTION = "actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c"; export const CLI_ARTIFACT_UPLOAD_ACTION = "actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a"; -export const CLI_ARTIFACT_RESTORE_ACTION = - "NVIDIA/NemoClaw/.github/actions/restore-e2e-cli-artifact@c246409193a31133cab10c8a3589001cc0d59eb3"; +export const CLI_ARTIFACT_RESTORE_ACTION = E2E_ACTION_PROVENANCE.restoreCliArtifact.reference; export const CLI_ARTIFACT_PACKAGE_STEP = "Package exact-commit CLI"; export const CLI_ARTIFACT_PUBLISH_STEP = "Publish content-addressed CLI artifact"; export const CLI_ARTIFACT_RESTORE_STEP = "Restore exact-commit CLI artifact"; @@ -32,8 +32,7 @@ const DEFAULT_RESTORE_ACTION_PATH = join( "restore-e2e-cli-artifact", "action.yaml", ); -const RESTORE_ACTION_CONTENT_SHA256 = - "3a81ad631b839aa938eaaf1ad6777bab247204bf86fbca3c43c326a44dfb9c6c"; +const RESTORE_ACTION_CONTENT_SHA256 = E2E_ACTION_PROVENANCE.restoreCliArtifact.contentSha256; const CLI_ARTIFACT_DOWNLOAD_STEP = "Download exact-commit CLI artifact"; const CLI_ARTIFACT_VERIFY_STEP = "Verify and restore exact-commit CLI artifact"; const CLI_ARTIFACT_PROVENANCE_STEP = "Record CLI artifact provenance"; @@ -41,72 +40,6 @@ const CANDIDATE_CHECKOUT_STEP_CONTENT_SHA256 = "3578a053cede863f7aa4814d8399b4ca21ea0b77cee712e6d549c684818f11dd"; const CLI_ARTIFACT_WORKFLOW_CONTRACT_SHA256 = "604afc60e21ba46c2099f23577bbc0dda69e03ee09a12ed94db0713237def237"; -const CLI_ARTIFACT_CONSUMER_JOB_NAMES = [ - "agent-turn-latency", - "bedrock-runtime-compatible-anthropic", - "brave-search", - "channels-add-remove", - "channels-stop-start", - "cloud-inference", - "cloud-onboard", - "common-egress-agent", - "concurrent-gateway-ports", - "cron-preflight-inference-local", - "dashboard-remote-bind", - "device-auth-health", - "double-onboard", - "full-e2e", - "gateway-guard-recovery", - "gpu-double-onboard", - "gpu-e2e", - "hermes-discord", - "hermes-e2e", - "hermes-gpu-startup", - "hermes-inference-switch", - "hermes-shields-config", - "hermes-slack", - "inference-routing", - "issue-2478-crash-loop-recovery", - "issue-4434-tui-unreachable-inference", - "issue-4462-scope-upgrade-approval", - "jetson-nvmap-gpu", - "kimi-inference-compat", - "live", - "llama-cpp-generic-gpu", - "mcp-bridge", - "mcp-bridge-dev", - "messaging-compatible-endpoint", - "messaging-providers", - "model-router-provider-routed-inference", - "network-policy", - "onboard-repair", - "onboard-resume", - "openclaw-discord-pairing", - "openclaw-inference-switch", - "openclaw-plugin-runtime-exdev", - "openclaw-plugin-runtime-exdev-release", - "openclaw-skill-cli", - "openclaw-slack-pairing", - "openclaw-tui-chat-correlation", - "openshell-credential-generation-window", - "openshell-gateway-auth-contract", - "openshell-gateway-upgrade", - "overlayfs-autofix", - "rebuild-hermes", - "rebuild-hermes-stale-base", - "rebuild-openclaw", - "retired-selector-compatibility", - "sandbox-operations", - "sandbox-survival", - "security-posture", - "sessions-agents-cli", - "shared-e2e", - "skill-agent", - "state-backup-restore", - "telegram-injection", - "token-rotation", - "tunnel-lifecycle", -] as const; type WorkflowRecord = Record; type WorkflowStep = WorkflowRecord & { @@ -490,11 +423,8 @@ export function validateCliArtifactWorkflowBoundary( } const actualConsumerJobNames = [...consumerJobNames].sort(); - if (!isDeepStrictEqual(actualConsumerJobNames, CLI_ARTIFACT_CONSUMER_JOB_NAMES)) { - errors.push("CLI artifact consumer job names must match the required list"); - } const consumers = Object.fromEntries( - CLI_ARTIFACT_CONSUMER_JOB_NAMES.map((jobName) => [ + actualConsumerJobNames.map((jobName) => [ jobName, jobSettingsAndStepsThroughRestore(record(jobs[jobName])), ]), diff --git a/tools/e2e/prepare-e2e-workflow-boundary.mts b/tools/e2e/prepare-e2e-workflow-boundary.mts index 77c535e8d1e..a83c07e34ce 100644 --- a/tools/e2e/prepare-e2e-workflow-boundary.mts +++ b/tools/e2e/prepare-e2e-workflow-boundary.mts @@ -8,34 +8,24 @@ import { fileURLToPath } from "node:url"; import { isDeepStrictEqual } from "node:util"; import YAML from "yaml"; import { SHARED_E2E_JOB_ID } from "./credential-free-tests.mts"; +import { E2E_ACTION_PROVENANCE, E2E_JOB_POLICY } from "./workflow-boundary-policy.mts"; const REPO_ROOT = join(dirname(fileURLToPath(import.meta.url)), "..", ".."); const DEFAULT_ACTION_PATH = join(REPO_ROOT, ".github", "actions", "prepare-e2e", "action.yaml"); -const PREPARE_E2E_ACTION_PROVENANCE = { - reference: "NVIDIA/NemoClaw/.github/actions/prepare-e2e@f6304bc25fc35bfaa441c8c2fbfee38f72805a75", - contentSha256: "1283c2eadfbc38ccb3b795684ba5ced9c89ae2040fffbb6b81854a9d1926802b", -} as const; +const PREPARE_E2E_ACTION_PROVENANCE = E2E_ACTION_PROVENANCE.prepareWorkspace; export const PREPARE_E2E_ACTION = PREPARE_E2E_ACTION_PROVENANCE.reference; export const PREPARE_E2E_STEP = "Prepare E2E workspace"; const CHECKOUT_LOCAL_PREPARE_E2E_ACTION = "./.github/actions/prepare-e2e"; -export const CLI_ARTIFACT_PRODUCER_JOB = "generate-matrix"; +export const CLI_ARTIFACT_PRODUCER_JOB = E2E_JOB_POLICY.cliArtifactProducer; const PREINSTALLED_E2E_JOBS = new Set(["staging-brev-launchable"]); const RETIRED_SELECTOR_COMPATIBILITY_JOB = "retired-selector-compatibility"; -export const PREPARE_E2E_NO_BUILD_JOBS = new Set([ - "bootstrap-install-smoke", - "llama-cpp-dgx-spark-qualification", - "managed-image-multiarch-startup", - "ollama-auth-proxy", - "shields-config", - "snapshot-commands", - "spark-install", -]); - -export const PREPARE_E2E_TRUSTED_BUILD_JOBS = new Set(["managed-image-protected-runtime"]); +export const PREPARE_E2E_NO_BUILD_JOBS = new Set(E2E_JOB_POLICY.prepareNoBuild); + +export const PREPARE_E2E_TRUSTED_BUILD_JOBS = new Set(E2E_JOB_POLICY.prepareTrustedBuild); type WorkflowRecord = Record; type WorkflowStep = WorkflowRecord & { diff --git a/tools/e2e/sandbox-images-workflow-boundary.mts b/tools/e2e/sandbox-images-workflow-boundary.mts index 4efbdc9ab84..e7aca8fd486 100644 --- a/tools/e2e/sandbox-images-workflow-boundary.mts +++ b/tools/e2e/sandbox-images-workflow-boundary.mts @@ -119,7 +119,6 @@ const GUARDED_PRODUCTION_BUILD_CONTRACTS: readonly GuardedProductionBuildContrac }, ]; - type WorkflowRecord = Record; export type SandboxImagesWorkflowStep = WorkflowRecord & { @@ -754,6 +753,7 @@ function validateRuntimeImageReuse(errors: string[], workflow: SandboxImagesWork name: "isolation-image", path: "/tmp/isolation-image.tar.gz", "retention-days": 1, + "if-no-files-found": "error", }) || stepIndex(producer, save.name ?? "") >= stepIndex(producer, isolationUpload.name ?? "") ) { @@ -985,6 +985,7 @@ function validateHermesImageReuse(errors: string[], workflow: SandboxImagesWorkf name: "hermes-isolation-image", path: "/tmp/hermes-isolation-image.tar.gz", "retention-days": 1, + "if-no-files-found": "error", }) || stepIndex(producer, save.name ?? "") >= stepIndex(producer, upload.name ?? "") || stepIndex(producer, upload.name ?? "") >= stepIndex(producer, CLEANUP_STEP_NAME) diff --git a/tools/e2e/upload-e2e-artifacts-workflow-boundary.mts b/tools/e2e/upload-e2e-artifacts-workflow-boundary.mts index 7532cfb953a..c43e8420008 100644 --- a/tools/e2e/upload-e2e-artifacts-workflow-boundary.mts +++ b/tools/e2e/upload-e2e-artifacts-workflow-boundary.mts @@ -12,6 +12,9 @@ import { CLI_ARTIFACT_UPLOAD_ACTION, } from "./cli-artifact-workflow-boundary.mts"; import { SHARED_E2E_JOB_ID } from "./credential-free-tests.mts"; +import { E2E_ACTION_PROVENANCE } from "./workflow-boundary-policy.mts"; + +export { E2E_ACTION_PROVENANCE }; const REPO_ROOT = join(dirname(fileURLToPath(import.meta.url)), "..", ".."); const DEFAULT_ACTION_PATH = join( @@ -22,11 +25,7 @@ const DEFAULT_ACTION_PATH = join( "action.yaml", ); -export const UPLOAD_E2E_ARTIFACTS_ACTION_PROVENANCE = { - reference: - "NVIDIA/NemoClaw/.github/actions/upload-e2e-artifacts@7768e15eb90d3ee2d33432f481dfe8747e4f6d57", - contentSha256: "8f6f71a0e6d71d85418fa88c2b26a4d601f568bdcaae20aca4085ae423c5044b", -} as const; +export const UPLOAD_E2E_ARTIFACTS_ACTION_PROVENANCE = E2E_ACTION_PROVENANCE.uploadArtifacts; export const UPLOAD_E2E_ARTIFACTS_ACTION = UPLOAD_E2E_ARTIFACTS_ACTION_PROVENANCE.reference; diff --git a/tools/e2e/workflow-boundary-policy.mts b/tools/e2e/workflow-boundary-policy.mts new file mode 100644 index 00000000000..5b11e6fe8bd --- /dev/null +++ b/tools/e2e/workflow-boundary-policy.mts @@ -0,0 +1,53 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +export const E2E_ACTION_PROVENANCE = { + prepareWorkspace: { + reference: + "NVIDIA/NemoClaw/.github/actions/prepare-e2e@f6304bc25fc35bfaa441c8c2fbfee38f72805a75", + contentSha256: "1283c2eadfbc38ccb3b795684ba5ced9c89ae2040fffbb6b81854a9d1926802b", + }, + restoreCliArtifact: { + reference: + "NVIDIA/NemoClaw/.github/actions/restore-e2e-cli-artifact@c246409193a31133cab10c8a3589001cc0d59eb3", + contentSha256: "3a81ad631b839aa938eaaf1ad6777bab247204bf86fbca3c43c326a44dfb9c6c", + }, + uploadArtifacts: { + reference: + "NVIDIA/NemoClaw/.github/actions/upload-e2e-artifacts@7768e15eb90d3ee2d33432f481dfe8747e4f6d57", + contentSha256: "8f6f71a0e6d71d85418fa88c2b26a4d601f568bdcaae20aca4085ae423c5044b", + }, + dockerAuth: { + reference: + "NVIDIA/NemoClaw/.github/actions/docker-auth-setup@78091da47e290f49b8fe3f3e70b72362a0853928", + actionSha256: "cf93dcbd19589a56d1d58225fd6b3f8ad2180705662ff79a3407f340b5dba4c0", + scriptSha256: "853a3f742f057c29ed465b63bed1ec8d8f306a1c046877a8556cadf290ef0cb6", + }, + dockerCleanup: { + reference: + "NVIDIA/NemoClaw/.github/actions/docker-auth-cleanup@d5f37099766ca82a4516e7d8f0de117cda197fe3", + actionSha256: "8b7bf4bdb793ddd27aa9bab2e38157e91f0401148f6ba684acb516fc75e8d367", + scriptSha256: "4e5ce850c28f309b97695d61e11bcf1f154eae2b1d58c9697a3f49631c76abb4", + }, + hostDependencies: { + reference: + "NVIDIA/NemoClaw/.github/actions/host-dependency-setup@4def1501b34ce586f83b91af50a66b5d22b31d75", + actionSha256: "1ac05a0e0a0159fa0850eb82fccb0704d0e49b15bc6f2d6e3b6bb04c7ab94923", + scriptSha256: "2e910ed80b5dcf9aaf94230371fe586376c46f6df8fcbd76229063cbda1852c8", + }, +} as const; + +export const E2E_JOB_POLICY = { + cliArtifactProducer: "generate-matrix", + prepareNoBuild: [ + "bootstrap-install-smoke", + "llama-cpp-dgx-spark-qualification", + "managed-image-multiarch-startup", + "ollama-auth-proxy", + "shields-config", + "snapshot-commands", + "spark-install", + "whatsapp-qr-compact", + ], + prepareTrustedBuild: ["managed-image-protected-runtime"], +} as const; diff --git a/tools/e2e/workflow-boundary.mts b/tools/e2e/workflow-boundary.mts index 8d307700bdf..e5dc27f027c 100644 --- a/tools/e2e/workflow-boundary.mts +++ b/tools/e2e/workflow-boundary.mts @@ -52,6 +52,7 @@ import { validateTrustedHermesSwapWorkflow, } from "./trusted-hermes-swap-workflow-boundary.mts"; import { + E2E_ACTION_PROVENANCE, UPLOAD_E2E_ARTIFACTS_ACTION, validateUploadE2eArtifactsWorkflowBoundary, } from "./upload-e2e-artifacts-workflow-boundary.mts"; @@ -189,25 +190,10 @@ const NO_IMAGE_E2E_JOBS = new Set(["staging-brev-launchable", SHARED_E2E_JOB_ID] const DOCKER_HUB_AUTH_STEP = "Authenticate to Docker Hub"; const DOCKER_HUB_CLEANUP_STEP = "Clean up Docker auth"; const DOCKER_HUB_CLEANUP_RUN = "bash .github/scripts/docker-auth-cleanup.sh"; -const DOCKER_HUB_AUTH_PROVENANCE = { - reference: - "NVIDIA/NemoClaw/.github/actions/docker-auth-setup@78091da47e290f49b8fe3f3e70b72362a0853928", - actionSha256: "cf93dcbd19589a56d1d58225fd6b3f8ad2180705662ff79a3407f340b5dba4c0", - scriptSha256: "853a3f742f057c29ed465b63bed1ec8d8f306a1c046877a8556cadf290ef0cb6", -} as const; -const DOCKER_HUB_CLEANUP_PROVENANCE = { - reference: - "NVIDIA/NemoClaw/.github/actions/docker-auth-cleanup@d5f37099766ca82a4516e7d8f0de117cda197fe3", - actionSha256: "8b7bf4bdb793ddd27aa9bab2e38157e91f0401148f6ba684acb516fc75e8d367", - scriptSha256: "4e5ce850c28f309b97695d61e11bcf1f154eae2b1d58c9697a3f49631c76abb4", -} as const; +const DOCKER_HUB_AUTH_PROVENANCE = E2E_ACTION_PROVENANCE.dockerAuth; +const DOCKER_HUB_CLEANUP_PROVENANCE = E2E_ACTION_PROVENANCE.dockerCleanup; const DOCKER_HUB_AUTH_USES = DOCKER_HUB_AUTH_PROVENANCE.reference; -const HOST_DEPENDENCY_ACTION_PROVENANCE = { - reference: - "NVIDIA/NemoClaw/.github/actions/host-dependency-setup@4def1501b34ce586f83b91af50a66b5d22b31d75", - actionSha256: "1ac05a0e0a0159fa0850eb82fccb0704d0e49b15bc6f2d6e3b6bb04c7ab94923", - scriptSha256: "2e910ed80b5dcf9aaf94230371fe586376c46f6df8fcbd76229063cbda1852c8", -} as const; +const HOST_DEPENDENCY_ACTION_PROVENANCE = E2E_ACTION_PROVENANCE.hostDependencies; const HOST_DEPENDENCY_ACTION_USES = HOST_DEPENDENCY_ACTION_PROVENANCE.reference; const DOCKER_HUB_CLEANUP_KEYS = ["if", "name", "run", "shell"]; // The general E2E workflow runs on push/manual dispatch. Its event set is @@ -2797,7 +2783,6 @@ function validateHermesE2EJob(errors: string[], jobs: WorkflowRecord): void { if (asRecord(checkout?.with)["persist-credentials"] !== false) { errors.push("hermes-e2e checkout step must set persist-credentials=false"); } - const runVitest = requireJobStep(errors, jobName, steps, "Run Hermes live Vitest test"); const runVitestEnv = asRecord(runVitest?.env); if (runVitestEnv.NVIDIA_INFERENCE_API_KEY !== GUARDED_HERMES_E2E_INFERENCE_KEY) { @@ -4973,6 +4958,7 @@ export function validateE2eWorkflow(workflowValue: unknown): string[] { validateStagingBrevLaunchableJob(errors, jobs); validateSkillAgentJob(errors, jobs); validateFreeStandingJobSelector(errors, jobs, "sessions-agents-cli", "sessions-agents-cli"); + validateFreeStandingJobSelector(errors, jobs, "whatsapp-qr-compact", "whatsapp-qr-compact"); validateFreeStandingJobSelector(errors, jobs, "inference-routing", "inference-routing"); validateInferenceRoutingJob(errors, jobs); validateCloudInferenceJob(errors, jobs);