diff --git a/.github/workflows/e2e.yaml b/.github/workflows/e2e.yaml index edc150f8fb..43971788b2 100644 --- a/.github/workflows/e2e.yaml +++ b/.github/workflows/e2e.yaml @@ -2184,17 +2184,30 @@ jobs: run: bash .github/scripts/docker-auth-cleanup.sh network-policy: + name: Network policy (${{ matrix.scenario }}) needs: generate-matrix if: ${{ (github.event_name != 'workflow_dispatch' || (inputs.jobs == '' && inputs.targets == '')) || contains(format(',{0},', inputs.jobs), ',network-policy,') || contains(format(',{0},', inputs.targets), ',network-policy,') }} runs-on: ubuntu-latest timeout-minutes: 90 + strategy: + fail-fast: false + matrix: + include: + - scenario: live-probes + selector: "^network-policy:.+probes$" + sandbox: e2e-net-policy-live-probes + - scenario: zero-presets + selector: "^network-policy:.+presets$" + sandbox: e2e-net-policy-zero-presets env: E2E_JOB: "1" E2E_TARGET_ID: "network-policy" - E2E_ARTIFACT_DIR: ${{ github.workspace }}/e2e-artifacts/live/network-policy + E2E_ARTIFACT_DIR: ${{ github.workspace }}/e2e-artifacts/live/network-policy/${{ matrix.scenario }} NEMOCLAW_CLI_BIN: ${{ github.workspace }}/bin/nemoclaw.js + NEMOCLAW_E2E_SHARD: ${{ matrix.scenario }} NEMOCLAW_RUN_LIVE_E2E: "1" NEMOCLAW_E2E_USE_HOSTED_INFERENCE: "1" + NEMOCLAW_SANDBOX_NAME: ${{ matrix.sandbox }} # Raw OpenShell sandbox commands in the migrated live test must target # the gateway registered by NemoClaw onboarding even when OpenShell has # no active gateway selected on the runner. @@ -2236,11 +2249,15 @@ jobs: NVIDIA_INFERENCE_API_KEY: ${{ secrets.NVIDIA_INFERENCE_API_KEY }} run: | set -euo pipefail - npx tsx tools/e2e/live-vitest-invocation.mts run --test-path test/e2e/live/network-policy.test.ts + npx tsx tools/e2e/live-vitest-invocation.mts run --test-path test/e2e/live/network-policy.test.ts \ + --selector "${{ matrix.selector }}" - name: Upload network-policy artifacts if: always() uses: NVIDIA/NemoClaw/.github/actions/upload-e2e-artifacts@7768e15eb90d3ee2d33432f481dfe8747e4f6d57 + with: + name: e2e-network-policy-${{ matrix.scenario }} + path: e2e-artifacts/live/network-policy/${{ matrix.scenario }}/ - name: Clean up Docker auth if: always() @@ -2248,15 +2265,28 @@ jobs: run: bash .github/scripts/docker-auth-cleanup.sh common-egress-agent: + name: Common egress agent (${{ matrix.scenario }}) needs: generate-matrix if: ${{ (github.event_name != 'workflow_dispatch' || (inputs.jobs == '' && inputs.targets == '')) || contains(format(',{0},', inputs.jobs), ',common-egress-agent,') || contains(format(',{0},', inputs.targets), ',common-egress-agent,') }} runs-on: ${{ fromJSON(needs.generate-matrix.outputs.runner_routing)['common-egress-agent'] }} - timeout-minutes: 120 + timeout-minutes: 60 + strategy: + fail-fast: false + max-parallel: 2 + matrix: + include: + - scenario: openclaw-balanced-weather + selector: "^common-egress.+C1.+$" + - scenario: openclaw-open-reference + selector: "^common-egress.+C2.+$" + - scenario: hermes-open-reference + selector: "^common-egress.+C3.+$" env: E2E_JOB: "1" E2E_TARGET_ID: "common-egress-agent" - E2E_ARTIFACT_DIR: ${{ github.workspace }}/e2e-artifacts/live/common-egress-agent + E2E_ARTIFACT_DIR: ${{ github.workspace }}/e2e-artifacts/live/common-egress-agent/${{ matrix.scenario }} NEMOCLAW_CLI_BIN: ${{ github.workspace }}/bin/nemoclaw.js + NEMOCLAW_E2E_SHARD: ${{ matrix.scenario }} NEMOCLAW_RUN_LIVE_E2E: "1" NEMOCLAW_E2E_USE_HOSTED_INFERENCE: "1" NEMOCLAW_ACCEPT_THIRD_PARTY_SOFTWARE: "1" @@ -2311,7 +2341,8 @@ jobs: export OPENSHELL_BIN echo "Using OPENSHELL_BIN=$OPENSHELL_BIN" "$OPENSHELL_BIN" --version - npx tsx tools/e2e/live-vitest-invocation.mts run --test-path test/e2e/live/common-egress-agent.test.ts + npx tsx tools/e2e/live-vitest-invocation.mts run --test-path test/e2e/live/common-egress-agent.test.ts \ + --selector "${{ matrix.selector }}" - name: Finalize runner comparison telemetry if: ${{ always() && github.repository == 'NVIDIA/NemoClaw' && github.ref == 'refs/heads/main' && inputs.checkout_sha == '' }} @@ -2322,6 +2353,9 @@ jobs: - name: Upload common-egress agent artifacts if: always() uses: NVIDIA/NemoClaw/.github/actions/upload-e2e-artifacts@7768e15eb90d3ee2d33432f481dfe8747e4f6d57 + with: + name: e2e-common-egress-agent-${{ matrix.scenario }} + path: e2e-artifacts/live/common-egress-agent/${{ matrix.scenario }}/ - name: Clean up Docker auth if: always() @@ -3352,6 +3386,8 @@ jobs: - name: Prepare E2E workspace uses: NVIDIA/NemoClaw/.github/actions/prepare-e2e@50281ee84c4a6fc759da95ea28fc0b7d9c378a28 + with: + build-cli: "false" - name: Initialize runner comparison telemetry if: ${{ github.repository == 'NVIDIA/NemoClaw' && github.ref == 'refs/heads/main' && inputs.checkout_sha == '' && matrix.agent == 'hermes' }} diff --git a/test/e2e/README.md b/test/e2e/README.md index 4957fbadbe..9aba76f25d 100644 --- a/test/e2e/README.md +++ b/test/e2e/README.md @@ -139,10 +139,11 @@ graph as the live targets: ### Runner comparison telemetry Trusted `main` runs without an alternate checkout SHA record runner-comparison -telemetry for the #7145 contract: 12 routed workflow lane identities / 13 +telemetry for the #7145 contract: 12 routed workflow lane identities / 15 concrete job executions. -- `common-egress-agent` +- `common-egress-agent` with the `openclaw-balanced-weather`, + `openclaw-open-reference`, and `hermes-open-reference` shards - `rebuild-hermes` - `rebuild-hermes-stale-base` - `mcp-bridge` with the `hermes` shard @@ -155,8 +156,9 @@ concrete job executions. - `hermes-shields-config` - `security-posture` with the `hermes` shard -The extra execution comes from `hermes-inference-switch`, which runs both -listed modes. The OpenClaw matrix entries for `mcp-bridge`, +The three extra executions come from `common-egress-agent`, which runs three +scenario shards, and `hermes-inference-switch`, which runs both listed modes. +The OpenClaw matrix entries for `mcp-bridge`, `channels-stop-start`, and `security-posture` are not instrumented. Each execution writes two best-effort numeric samples to diff --git a/test/e2e/support/e2e-cleanup-resources.test.ts b/test/e2e/support/e2e-cleanup-resources.test.ts index 234ba1ead0..d992545eb4 100644 --- a/test/e2e/support/e2e-cleanup-resources.test.ts +++ b/test/e2e/support/e2e-cleanup-resources.test.ts @@ -109,6 +109,25 @@ describe("cleanup resources", () => { expect(calls).toBe(1); }); + it("registers sandbox cleanup before installer side effects (#7146)", () => { + for (const fileName of ["full-e2e.test.ts", "hermes-e2e.test.ts"]) { + const source = fs.readFileSync( + path.resolve(import.meta.dirname, "..", "live", fileName), + "utf8", + ); + const cleanupRegistration = source.indexOf(".trackSandbox(host, SANDBOX_NAME"); + const installerStart = source.indexOf('host.command("bash", ["install.sh"'); + + expect( + cleanupRegistration, + `${fileName} must register sandbox cleanup`, + ).toBeGreaterThanOrEqual(0); + expect(installerStart, `${fileName} must invoke install.sh`).toBeGreaterThan( + cleanupRegistration, + ); + } + }); + it("continues typed cleanup after a resource failure", async () => { const calls: string[] = []; const host: CleanupHost = { diff --git a/test/e2e/support/e2e-workflow.test.ts b/test/e2e/support/e2e-workflow.test.ts index 0fbcc289b9..af290259c9 100644 --- a/test/e2e/support/e2e-workflow.test.ts +++ b/test/e2e/support/e2e-workflow.test.ts @@ -62,6 +62,97 @@ describe("e2e workflow boundary", () => { ); }); + it("keeps network-policy scenarios isolated with cleanup reserve", () => { + const workflow = readWorkflow() as { + jobs: Record< + string, + { + env: Record; + steps: Array<{ name?: string; run?: string; with?: Record }>; + strategy: { + "fail-fast": boolean; + matrix: { include: Array> }; + }; + "timeout-minutes": number; + } + >; + }; + const job = workflow.jobs["network-policy"]!; + const source = fs.readFileSync("test/e2e/live/network-policy.test.ts", "utf8"); + expect(source).toContain("const TEST_TIMEOUT_MS = 65 * 60_000;"); + + job["timeout-minutes"] = 65; + job.strategy["fail-fast"] = true; + job.strategy.matrix.include.pop(); + job.env.E2E_ARTIFACT_DIR = "${{ github.workspace }}/e2e-artifacts/live/network-policy"; + delete job.env.NEMOCLAW_E2E_SHARD; + delete job.env.NEMOCLAW_SANDBOX_NAME; + const run = job.steps.find((step) => step.name === "Run network-policy live test")!; + run.run = run.run!.replace('--selector "${{ matrix.selector }}"', "--selector all"); + const upload = job.steps.find((step) => step.name === "Upload network-policy artifacts")!; + delete upload.with; + + expect(validateE2eWorkflow(workflow)).toEqual( + expect.arrayContaining([ + "network-policy scenario jobs must keep the 90 minute timeout", + "network-policy scenario matrix must disable fail-fast", + "network-policy job must keep the two isolated scenario shards", + "network-policy job must isolate artifacts by matrix.scenario", + "network-policy job must bind NEMOCLAW_E2E_SHARD to matrix.scenario", + "network-policy job must bind its sandbox name to matrix.sandbox", + `step 'Run network-policy live test' run script must include --selector "\${{ matrix.selector }}"`, + "network-policy upload-e2e-artifacts invocation must not override its contract", + "network-policy upload-e2e-artifacts must preserve its explicit name/path contract", + ]), + ); + }); + + it("keeps common-egress scenarios isolated with bounded concurrency and cleanup reserve", () => { + const workflow = readWorkflow() as { + jobs: Record< + string, + { + env: Record; + steps: Array<{ name?: string; run?: string; with?: Record }>; + strategy: { + "fail-fast": boolean; + "max-parallel": number; + matrix: { include: Array> }; + }; + "timeout-minutes": number; + } + >; + }; + const job = workflow.jobs["common-egress-agent"]!; + const source = fs.readFileSync("test/e2e/live/common-egress-agent.test.ts", "utf8"); + expect(source).toContain("const TEST_TIMEOUT_MS = 40 * 60_000;"); + + job["timeout-minutes"] = 40; + job.strategy["fail-fast"] = true; + job.strategy["max-parallel"] = 3; + job.strategy.matrix.include.pop(); + job.env.E2E_ARTIFACT_DIR = "${{ github.workspace }}/e2e-artifacts/live/common-egress-agent"; + delete job.env.NEMOCLAW_E2E_SHARD; + const run = job.steps.find((step) => step.name === "Run common-egress agent live test")!; + run.run = run.run!.replace('--selector "${{ matrix.selector }}"', "--selector all"); + const upload = job.steps.find((step) => step.name === "Upload common-egress agent artifacts")!; + delete upload.with; + + expect(validateE2eWorkflow(workflow)).toEqual( + expect.arrayContaining([ + "common-egress-agent scenario jobs must keep the 60 minute timeout", + "common-egress-agent scenario matrix must disable fail-fast", + "common-egress-agent scenario matrix must cap concurrency at two", + "common-egress-agent job must keep the three isolated scenario shards", + "common-egress-agent job must isolate artifacts by matrix.scenario", + "common-egress-agent job must bind NEMOCLAW_E2E_SHARD to matrix.scenario", + `step 'Run common-egress agent live test' run script must include --selector "\${{ matrix.selector }}"`, + "common-egress-agent upload-e2e-artifacts invocation must not override its contract", + "common-egress-agent upload-e2e-artifacts must preserve its explicit name/path contract", + ]), + ); + }); + it("binds typed-target evidence identity and upload to the live matrix entry", () => { const workflow = readWorkflow() as { jobs: Record< diff --git a/test/e2e/support/prepare-e2e-workflow-boundary.test.ts b/test/e2e/support/prepare-e2e-workflow-boundary.test.ts index 8db06c0634..acc5f08276 100644 --- a/test/e2e/support/prepare-e2e-workflow-boundary.test.ts +++ b/test/e2e/support/prepare-e2e-workflow-boundary.test.ts @@ -32,6 +32,20 @@ describe("prepare-e2e workflow boundary", () => { expect(validatePrepareE2eInvocations(readWorkflow())).toEqual([]); }); + it("keeps the installer-backed security posture matrix on the no-build bootstrap", () => { + const workflow = readWorkflow() as Workflow; + const securityPostureJob = workflow.jobs["security-posture"]; + const prepare = securityPostureJob.steps!.find((step) => step.uses === PREPARE_E2E_ACTION)!; + delete prepare.with; + + expect(validatePrepareE2eInvocations(workflow)).toEqual( + expect.arrayContaining([ + "security-posture prepare-e2e must set build-cli to false", + "security-posture prepare-e2e invocation must not override its canonical contract", + ]), + ); + }); + it("rejects action implementation drift", () => { const directory = fs.mkdtempSync(path.join(os.tmpdir(), "prepare-e2e-action-")); const actionPath = path.join(directory, "action.yaml"); diff --git a/test/e2e/support/upload-e2e-artifacts-workflow-boundary.test.ts b/test/e2e/support/upload-e2e-artifacts-workflow-boundary.test.ts index 45277222bd..8a548e566d 100644 --- a/test/e2e/support/upload-e2e-artifacts-workflow-boundary.test.ts +++ b/test/e2e/support/upload-e2e-artifacts-workflow-boundary.test.ts @@ -145,6 +145,8 @@ describe("upload-e2e-artifacts workflow boundary", () => { defaultJob.env!.E2E_TARGET_ID = "not a selector id"; uploadStep(workflow.jobs["hermes-slack"]).with!.path = "e2e-artifacts/live/hermes-slack/"; + uploadStep(workflow.jobs["network-policy"]).with!.name = "e2e-network-policy"; + uploadStep(workflow.jobs["common-egress-agent"]).with!.name = "e2e-common-egress-agent"; uploadStep(workflow.jobs["gpu-e2e"]).if = "success()"; uploadStep(workflow.jobs["mcp-bridge"]).if = "always()"; uploadStep(workflow.jobs["openshell-gateway-auth-contract"]).if = "always()"; @@ -163,6 +165,8 @@ describe("upload-e2e-artifacts workflow boundary", () => { "credential-migration upload-e2e-artifacts must use the action defaults", "credential-migration default upload caller must declare a valid E2E_TARGET_ID", "hermes-slack upload-e2e-artifacts must preserve its explicit name/path contract", + "network-policy upload-e2e-artifacts must preserve its explicit name/path contract", + "common-egress-agent upload-e2e-artifacts must preserve its explicit name/path contract", "gpu-e2e upload-e2e-artifacts invocation must run with always()", "mcp-bridge upload-e2e-artifacts invocation must remain gated by its reviewed pre-upload checks", "openshell-gateway-auth-contract upload-e2e-artifacts invocation must remain gated by its reviewed pre-upload checks", diff --git a/test/pr-e2e-gate-signal-shards.test.ts b/test/pr-e2e-gate-signal-shards.test.ts index c42c2b76fa..ccf154b758 100644 --- a/test/pr-e2e-gate-signal-shards.test.ts +++ b/test/pr-e2e-gate-signal-shards.test.ts @@ -48,7 +48,7 @@ describe("PR E2E signal shard policy", () => { const broadPlan = buildRiskPlan({ headSha: HEAD_SHA, changedFiles: BROAD_FILES }); const broadShards = expectedSignalShards(riskPlanRequiredJobIds(broadPlan)); expect(Object.keys(broadShards)).toHaveLength(13); - expect(Object.values(broadShards).flat()).toHaveLength(15); + expect(Object.values(broadShards).flat()).toHaveLength(16); expect(() => expectedSignalShards(["not-a-workflow-job"])).toThrow(/does not define/u); }); }); diff --git a/tools/e2e/prepare-e2e-workflow-boundary.mts b/tools/e2e/prepare-e2e-workflow-boundary.mts index 3f3bc172f1..094ba41ea7 100644 --- a/tools/e2e/prepare-e2e-workflow-boundary.mts +++ b/tools/e2e/prepare-e2e-workflow-boundary.mts @@ -27,6 +27,7 @@ const NO_BUILD_JOBS = new Set([ "generate-matrix", "bootstrap-install-smoke", "ollama-auth-proxy", + "security-posture", "shields-config", "snapshot-commands", "spark-install", diff --git a/tools/e2e/upload-e2e-artifacts-workflow-boundary.mts b/tools/e2e/upload-e2e-artifacts-workflow-boundary.mts index 12e30c6981..9f503c2f62 100644 --- a/tools/e2e/upload-e2e-artifacts-workflow-boundary.mts +++ b/tools/e2e/upload-e2e-artifacts-workflow-boundary.mts @@ -115,6 +115,20 @@ const EXPLICIT_UPLOAD_CONTRACTS = new Map([ path: "e2e-artifacts/live/hermes-inference-switch/${{ matrix.mode }}/", }, ], + [ + "network-policy", + { + name: "e2e-network-policy-${{ matrix.scenario }}", + path: "e2e-artifacts/live/network-policy/${{ matrix.scenario }}/", + }, + ], + [ + "common-egress-agent", + { + name: "e2e-common-egress-agent-${{ matrix.scenario }}", + path: "e2e-artifacts/live/common-egress-agent/${{ matrix.scenario }}/", + }, + ], [ "hermes-gpu-startup", { diff --git a/tools/e2e/workflow-boundary.mts b/tools/e2e/workflow-boundary.mts index 1be483e8fe..51f4f980ea 100644 --- a/tools/e2e/workflow-boundary.mts +++ b/tools/e2e/workflow-boundary.mts @@ -193,6 +193,36 @@ const MATRIX_ROUTED_JOB_RUNNER_EXPRESSIONS = { "security-posture": "${{ fromJSON(needs.generate-matrix.outputs.runner_routing)[format('security-posture-{0}', matrix.agent)] }}", } as const; +const NETWORK_POLICY_SCENARIO_MATRIX = { + include: [ + { + scenario: "live-probes", + selector: "^network-policy:.+probes$", + sandbox: "e2e-net-policy-live-probes", + }, + { + scenario: "zero-presets", + selector: "^network-policy:.+presets$", + sandbox: "e2e-net-policy-zero-presets", + }, + ], +} as const; +const COMMON_EGRESS_AGENT_SCENARIO_MATRIX = { + include: [ + { + scenario: "openclaw-balanced-weather", + selector: "^common-egress.+C1.+$", + }, + { + scenario: "openclaw-open-reference", + selector: "^common-egress.+C2.+$", + }, + { + scenario: "hermes-open-reference", + selector: "^common-egress.+C3.+$", + }, + ], +} as const; const ROUTED_JOB_NAMES = new Set([ ...Object.keys(ROUTED_JOB_RUNNER_EXPRESSIONS), ...Object.keys(MATRIX_ROUTED_JOB_RUNNER_EXPRESSIONS), @@ -1254,23 +1284,45 @@ function validateNetworkPolicyJob(errors: string[], jobs: WorkflowRecord): void if (job["runs-on"] !== "ubuntu-latest") { errors.push("network-policy job must run on ubuntu-latest"); } + if (job["timeout-minutes"] !== 90) { + errors.push("network-policy scenario jobs must keep the 90 minute timeout"); + } if (job.needs !== "generate-matrix") { errors.push("network-policy job must depend on generate-matrix"); } if (job.if !== freeStandingJobIf(jobName, "network-policy")) { errors.push("network-policy job must map targets=network-policy to the network-policy job"); } + if (job.name !== "Network policy (${{ matrix.scenario }})") { + errors.push("network-policy job name must identify matrix.scenario"); + } + const strategy = asRecord(job.strategy); + if (strategy["fail-fast"] !== false) { + errors.push("network-policy scenario matrix must disable fail-fast"); + } + if (!isDeepStrictEqual(asRecord(strategy.matrix), NETWORK_POLICY_SCENARIO_MATRIX)) { + errors.push("network-policy job must keep the two isolated scenario shards"); + } const jobEnv = asRecord(job.env); if (jobEnv.NEMOCLAW_RUN_LIVE_E2E !== "1") { errors.push("network-policy job must set NEMOCLAW_RUN_LIVE_E2E=1"); } - if (jobEnv.E2E_ARTIFACT_DIR !== "${{ github.workspace }}/e2e-artifacts/live/network-policy") { - errors.push("network-policy job must write artifacts under e2e-artifacts/live/network-policy"); + if ( + jobEnv.E2E_ARTIFACT_DIR !== + "${{ github.workspace }}/e2e-artifacts/live/network-policy/${{ matrix.scenario }}" + ) { + errors.push("network-policy job must isolate artifacts by matrix.scenario"); } if (!stringValue(jobEnv.NEMOCLAW_CLI_BIN).includes("bin/nemoclaw.js")) { errors.push("network-policy job must point NEMOCLAW_CLI_BIN at the repo CLI"); } + if (jobEnv.NEMOCLAW_E2E_SHARD !== "${{ matrix.scenario }}") { + errors.push("network-policy job must bind NEMOCLAW_E2E_SHARD to matrix.scenario"); + } + if (jobEnv.NEMOCLAW_SANDBOX_NAME !== "${{ matrix.sandbox }}") { + errors.push("network-policy job must bind its sandbox name to matrix.sandbox"); + } if (jobEnv.OPENSHELL_GATEWAY !== "nemoclaw") { errors.push("network-policy job must force OPENSHELL_GATEWAY=nemoclaw"); } @@ -1348,6 +1400,7 @@ function validateNetworkPolicyJob(errors: string[], jobs: WorkflowRecord): void } requireRunContains(errors, runVitest, "tools/e2e/live-vitest-invocation.mts run --test-path"); requireRunContains(errors, runVitest, "test/e2e/live/network-policy.test.ts"); + requireRunContains(errors, runVitest, '--selector "${{ matrix.selector }}"'); } function validateIssue4434HostDependencies(errors: string[], jobs: WorkflowRecord): void { @@ -1400,8 +1453,21 @@ function validateCommonEgressAgentJob(errors: string[], jobs: WorkflowRecord): v } validateFreeStandingJobSelector(errors, jobs, jobName, "common-egress-agent"); - if (job["timeout-minutes"] !== 120) { - errors.push("common-egress-agent job must keep the legacy 120 minute timeout"); + if (job.name !== "Common egress agent (${{ matrix.scenario }})") { + errors.push("common-egress-agent job name must identify matrix.scenario"); + } + if (job["timeout-minutes"] !== 60) { + errors.push("common-egress-agent scenario jobs must keep the 60 minute timeout"); + } + const strategy = asRecord(job.strategy); + if (strategy["fail-fast"] !== false) { + errors.push("common-egress-agent scenario matrix must disable fail-fast"); + } + if (strategy["max-parallel"] !== 2) { + errors.push("common-egress-agent scenario matrix must cap concurrency at two"); + } + if (!isDeepStrictEqual(asRecord(strategy.matrix), COMMON_EGRESS_AGENT_SCENARIO_MATRIX)) { + errors.push("common-egress-agent job must keep the three isolated scenario shards"); } const jobEnv = asRecord(job.env); @@ -1409,15 +1475,17 @@ function validateCommonEgressAgentJob(errors: string[], jobs: WorkflowRecord): v errors.push("common-egress-agent job must set NEMOCLAW_RUN_LIVE_E2E=1"); } if ( - jobEnv.E2E_ARTIFACT_DIR !== "${{ github.workspace }}/e2e-artifacts/live/common-egress-agent" + jobEnv.E2E_ARTIFACT_DIR !== + "${{ github.workspace }}/e2e-artifacts/live/common-egress-agent/${{ matrix.scenario }}" ) { - errors.push( - "common-egress-agent job must write artifacts under e2e-artifacts/live/common-egress-agent", - ); + errors.push("common-egress-agent job must isolate artifacts by matrix.scenario"); } if (!stringValue(jobEnv.NEMOCLAW_CLI_BIN).includes("bin/nemoclaw.js")) { errors.push("common-egress-agent job must point NEMOCLAW_CLI_BIN at the repo CLI"); } + if (jobEnv.NEMOCLAW_E2E_SHARD !== "${{ matrix.scenario }}") { + errors.push("common-egress-agent job must bind NEMOCLAW_E2E_SHARD to matrix.scenario"); + } if (jobEnv.NEMOCLAW_NON_INTERACTIVE !== "1") { errors.push("common-egress-agent job must set NEMOCLAW_NON_INTERACTIVE=1"); } @@ -1489,6 +1557,7 @@ function validateCommonEgressAgentJob(errors: string[], jobs: WorkflowRecord): v requireRunContains(errors, runVitest, "OPENSHELL_BIN"); requireRunContains(errors, runVitest, "tools/e2e/live-vitest-invocation.mts run --test-path"); requireRunContains(errors, runVitest, "test/e2e/live/common-egress-agent.test.ts"); + requireRunContains(errors, runVitest, '--selector "${{ matrix.selector }}"'); } function validateShieldsConfigJob(errors: string[], jobs: WorkflowRecord): void {