diff --git a/.github/workflows/e2e.yaml b/.github/workflows/e2e.yaml index 72fd7ff9e64..fb51b28309e 100644 --- a/.github/workflows/e2e.yaml +++ b/.github/workflows/e2e.yaml @@ -283,6 +283,7 @@ jobs: outputs: cli_artifact_provenance: ${{ steps.record_cli_artifact.outputs.provenance }} e2e_credentials_allowed: ${{ steps.e2e_credentials.outputs.allowed }} + managed_image_catalog: ${{ steps.resolve_pr_managed_image_catalog.outputs.catalog }} matrix: ${{ steps.matrix.outputs.matrix }} test_matrix: ${{ steps.matrix.outputs.test_matrix }} hermes_selected: ${{ steps.matrix.outputs.hermes_selected }} @@ -647,6 +648,33 @@ jobs: fi fi + - id: resolve_pr_managed_image_catalog + name: Resolve exact PR managed-image catalog + if: ${{ inputs.checkout_sha != '' && (inputs.jobs != 'native-runtime-qualification-producer' || inputs.targets != '') }} + env: + BASE_SHA: ${{ inputs.base_sha }} + CANDIDATE_REPOSITORY: ${{ inputs.checkout_repository }} + CANDIDATE_SHA: ${{ inputs.checkout_sha }} + GITHUB_TOKEN: ${{ github.token }} + PR_NUMBER: ${{ inputs.pr_number }} + shell: bash + run: | + set -euo pipefail + catalog_path="${RUNNER_TEMP}/pr-managed-image-catalog.json" + rm -f -- "$catalog_path" + node --experimental-strip-types --no-warnings \ + tools/e2e/pr-managed-image-publication.mts "$catalog_path" + if [[ -e "$catalog_path" ]]; then + [[ -f "$catalog_path" && ! -L "$catalog_path" && -s "$catalog_path" ]] || { + echo "::error::trusted PR managed-image catalog is not a nonempty regular file" >&2 + exit 1 + } + catalog="$(jq -c . "$catalog_path")" + catalog_sha256="$(printf '%s\n' "$catalog" | sha256sum | awk '{print $1}')" + printf 'catalog=%s\n' "$catalog" >>"$GITHUB_OUTPUT" + printf 'catalog_sha256=%s\n' "$catalog_sha256" >>"$GITHUB_OUTPUT" + fi + - name: Check out E2E candidate uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 if: ${{ inputs.checkout_sha == '' || inputs.jobs != 'native-runtime-qualification-producer' || inputs.targets != '' }} @@ -724,6 +752,8 @@ jobs: env: CANDIDATE_REPOSITORY: ${{ inputs.checkout_repository || github.repository }} CANDIDATE_SHA: ${{ inputs.checkout_sha || github.sha }} + MANAGED_IMAGE_CATALOG: ${{ steps.resolve_pr_managed_image_catalog.outputs.catalog }} + MANAGED_IMAGE_CATALOG_SHA256: ${{ steps.resolve_pr_managed_image_catalog.outputs.catalog_sha256 }} RUN_ATTEMPT: ${{ github.run_attempt }} RUN_ID: ${{ github.run_id }} WORKFLOW_SHA: ${{ github.workflow_sha }} @@ -761,6 +791,43 @@ jobs: ' dist/build-identity.json >/dev/null || { echo "::error::candidate CLI build identity does not match the candidate commit SHA"; exit 1; } + # BEGIN exact managed-image catalog staging + managed_catalog="${RUNNER_TEMP}/pr-managed-image-catalog.json" + artifact_catalog="dist/e2e-managed-image-catalog.json" + [[ ! -e "$artifact_catalog" && ! -L "$artifact_catalog" ]] || { + echo "::error::candidate build created the trusted managed-image catalog path" >&2 + exit 1 + } + if [[ -n "$MANAGED_IMAGE_CATALOG" ]]; then + [[ "$MANAGED_IMAGE_CATALOG_SHA256" =~ ^[a-f0-9]{64}$ ]] || { + echo "::error::trusted PR managed-image catalog digest is invalid" >&2 + exit 1 + } + [[ -f "$managed_catalog" && ! -L "$managed_catalog" && -s "$managed_catalog" ]] || { + echo "::error::trusted PR managed-image catalog is not a nonempty regular file" >&2 + exit 1 + } + [[ "$(sha256sum "$managed_catalog" | awk '{print $1}')" == "$MANAGED_IMAGE_CATALOG_SHA256" ]] || { + echo "::error::trusted PR managed-image catalog changed after authentication" >&2 + exit 1 + } + (umask 077 && set -o noclobber && printf '%s\n' "$MANAGED_IMAGE_CATALOG" >"$artifact_catalog") + [[ -f "$artifact_catalog" && ! -L "$artifact_catalog" && -s "$artifact_catalog" ]] || { + echo "::error::packaged PR managed-image catalog is invalid" >&2 + exit 1 + } + [[ "$(sha256sum "$artifact_catalog" | awk '{print $1}')" == "$MANAGED_IMAGE_CATALOG_SHA256" ]] || { + echo "::error::packaged PR managed-image catalog does not match trusted output" >&2 + exit 1 + } + else + [[ -z "$MANAGED_IMAGE_CATALOG_SHA256" && ! -e "$managed_catalog" && ! -L "$managed_catalog" ]] || { + echo "::error::managed-image catalog authority is inconsistent" >&2 + exit 1 + } + fi + # END exact managed-image catalog staging + artifact_dir="${RUNNER_TEMP}/nemoclaw-cli-artifact" install -d -m 0700 "$artifact_dir" payload="$artifact_dir/nemoclaw-cli.tar" diff --git a/test/e2e/support/pr-managed-image-publication.test.ts b/test/e2e/support/pr-managed-image-publication.test.ts index 18eb4bc35d9..7c8b27d3f9e 100644 --- a/test/e2e/support/pr-managed-image-publication.test.ts +++ b/test/e2e/support/pr-managed-image-publication.test.ts @@ -1,11 +1,14 @@ // SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 +import { createHash } from "node:crypto"; +import { spawnSync } from "node:child_process"; import fs from "node:fs"; import os from "node:os"; import path from "node:path"; import { describe, expect, it } from "vitest"; +import YAML from "yaml"; import { MANAGED_IMAGE_CAPABILITY_CONTRACT_VERSION, @@ -22,12 +25,21 @@ import { main, managedImagePublicationRequired, parseManagedImagePullRequestPaths, + resolvePrManagedImageCatalog, selectManagedImagePublicationRun, } from "../../../tools/e2e/pr-managed-image-publication.mts"; +import { + readE2eOperationsWorkflow, + validateE2eOperationsWorkflow, +} from "../../../tools/e2e/operations-workflow-boundary.mts"; +const BASE_SHA = "b".repeat(40); const CANDIDATE_SHA = "a".repeat(40); +const BASE_TREE_SHA = "c".repeat(40); +const CANDIDATE_TREE_SHA = "d".repeat(40); const PR_NUMBER = 8746; const WORKFLOW_ID = 12345; +const MANAGED_IMAGE_WORKFLOW = fs.readFileSync(".github/workflows/managed-images.yaml", "utf8"); function contract(agent: ManagedImageAgent, index: number): ManagedImageContractV1 { const image = MANAGED_IMAGE_REPOSITORIES[agent]; @@ -73,6 +85,39 @@ function run(overrides: Record = {}): unknown { }; } +function exactCommitRequest(changedPath: string, onUnexpected: (apiPath: string) => unknown) { + const baseBlob = "1".repeat(40); + const candidateBlob = "2".repeat(40); + return async (apiPath: string): Promise => { + switch (apiPath) { + case `/repos/NVIDIA/NemoClaw/pulls/${PR_NUMBER}`: + return { + state: "open", + base: { sha: BASE_SHA, repo: { full_name: "NVIDIA/NemoClaw" } }, + head: { sha: CANDIDATE_SHA, repo: { full_name: "NVIDIA/NemoClaw" } }, + }; + case `/repos/NVIDIA/NemoClaw/git/commits/${BASE_SHA}`: + return { sha: BASE_SHA, tree: { sha: BASE_TREE_SHA } }; + case `/repos/NVIDIA/NemoClaw/git/commits/${CANDIDATE_SHA}`: + return { sha: CANDIDATE_SHA, tree: { sha: CANDIDATE_TREE_SHA } }; + case `/repos/NVIDIA/NemoClaw/git/trees/${BASE_TREE_SHA}?recursive=1`: + return { + sha: BASE_TREE_SHA, + truncated: false, + tree: [{ path: changedPath, mode: "100644", type: "blob", sha: baseBlob }], + }; + case `/repos/NVIDIA/NemoClaw/git/trees/${CANDIDATE_TREE_SHA}?recursive=1`: + return { + sha: CANDIDATE_TREE_SHA, + truncated: false, + tree: [{ path: changedPath, mode: "100644", type: "blob", sha: candidateBlob }], + }; + default: + return onUnexpected(apiPath); + } + }; +} + describe("exact PR managed-image publication (#8746, #9464)", () => { it("derives applicability from the trusted managed-image workflow", () => { const patterns = parseManagedImagePullRequestPaths( @@ -109,6 +154,228 @@ on: ).toThrow("unsupported glob"); }); + it("classifies immutable commit trees without reading the mutable PR file listing", async () => { + const requests: string[] = []; + const request = exactCommitRequest("docs/upgrade.md", (apiPath) => { + requests.push(apiPath); + throw new Error(`unexpected request: ${apiPath}`); + }); + + await expect( + resolvePrManagedImageCatalog( + { + baseSha: BASE_SHA, + candidateRepository: "NVIDIA/NemoClaw", + candidateSha: CANDIDATE_SHA, + outputPath: path.join(os.tmpdir(), "unused-pr-managed-image-catalog.json"), + prNumber: PR_NUMBER, + token: "test-token", + workflowSource: MANAGED_IMAGE_WORKFLOW, + }, + async (apiPath) => { + requests.push(apiPath); + return request(apiPath); + }, + ), + ).resolves.toBe("not-required"); + expect(requests).toContain(`/repos/NVIDIA/NemoClaw/git/commits/${BASE_SHA}`); + expect(requests).toContain(`/repos/NVIDIA/NemoClaw/git/commits/${CANDIDATE_SHA}`); + expect(requests.some((apiPath) => apiPath.includes(`/pulls/${PR_NUMBER}/files`))).toBe(false); + }); + + it("requires exact publication after an immutable managed-image input change", async () => { + const request = exactCommitRequest("agents/hermes/plugin/__init__.py", (apiPath) => { + throw new Error(`publication lookup reached: ${apiPath}`); + }); + + await expect( + resolvePrManagedImageCatalog( + { + baseSha: BASE_SHA, + candidateRepository: "NVIDIA/NemoClaw", + candidateSha: CANDIDATE_SHA, + outputPath: path.join(os.tmpdir(), "unused-pr-managed-image-catalog.json"), + prNumber: PR_NUMBER, + token: "test-token", + workflowSource: MANAGED_IMAGE_WORKFLOW, + }, + request, + ), + ).rejects.toThrow("publication lookup reached: /repos/NVIDIA/NemoClaw/actions/workflows"); + }); + + it("rejects a truncated immutable commit tree", async () => { + const request = exactCommitRequest("docs/upgrade.md", (apiPath) => { + throw new Error(`unexpected request: ${apiPath}`); + }); + const truncatedTreePath = `/repos/NVIDIA/NemoClaw/git/trees/${BASE_TREE_SHA}?recursive=1`; + const substitutedResponses = new Map([ + [ + truncatedTreePath, + { + sha: BASE_TREE_SHA, + truncated: true, + tree: [ + { + path: "docs/upgrade.md", + mode: "100644", + type: "blob", + sha: "1".repeat(40), + }, + ], + }, + ], + ]); + await expect( + resolvePrManagedImageCatalog( + { + baseSha: BASE_SHA, + candidateRepository: "NVIDIA/NemoClaw", + candidateSha: CANDIDATE_SHA, + outputPath: path.join(os.tmpdir(), "unused-pr-managed-image-catalog.json"), + prNumber: PR_NUMBER, + token: "test-token", + workflowSource: MANAGED_IMAGE_WORKFLOW, + }, + async (apiPath) => substitutedResponses.get(apiPath) ?? request(apiPath), + ), + ).rejects.toThrow("PR base commit tree is truncated"); + }); + + it.each([ + [ + "duplicate directories", + [ + { path: "agents", mode: "040000", type: "tree", sha: "1".repeat(40) }, + { path: "agents", mode: "040000", type: "tree", sha: "2".repeat(40) }, + ], + ], + [ + "directory and file collisions", + [ + { path: "agents", mode: "040000", type: "tree", sha: "1".repeat(40) }, + { path: "agents", mode: "100644", type: "blob", sha: "2".repeat(40) }, + ], + ], + ])("rejects %s in an immutable commit tree", async (_description, tree) => { + const request = exactCommitRequest("docs/upgrade.md", (apiPath) => { + throw new Error(`unexpected request: ${apiPath}`); + }); + const baseTreePath = `/repos/NVIDIA/NemoClaw/git/trees/${BASE_TREE_SHA}?recursive=1`; + + await expect( + resolvePrManagedImageCatalog( + { + baseSha: BASE_SHA, + candidateRepository: "NVIDIA/NemoClaw", + candidateSha: CANDIDATE_SHA, + outputPath: path.join(os.tmpdir(), "unused-pr-managed-image-catalog.json"), + prNumber: PR_NUMBER, + token: "test-token", + workflowSource: MANAGED_IMAGE_WORKFLOW, + }, + async (apiPath) => + apiPath === baseTreePath + ? { sha: BASE_TREE_SHA, truncated: false, tree } + : request(apiPath), + ), + ).rejects.toThrow("PR base commit tree contains duplicate paths"); + }); + + it.each([ + ["blob", "040000"], + ["commit", "100644"], + ["tree", "100755"], + ])("rejects an immutable %s entry with Git mode %s", async (type, mode) => { + const request = exactCommitRequest("docs/upgrade.md", (apiPath) => { + throw new Error(`unexpected request: ${apiPath}`); + }); + const baseTreePath = `/repos/NVIDIA/NemoClaw/git/trees/${BASE_TREE_SHA}?recursive=1`; + + await expect( + resolvePrManagedImageCatalog( + { + baseSha: BASE_SHA, + candidateRepository: "NVIDIA/NemoClaw", + candidateSha: CANDIDATE_SHA, + outputPath: path.join(os.tmpdir(), "unused-pr-managed-image-catalog.json"), + prNumber: PR_NUMBER, + token: "test-token", + workflowSource: MANAGED_IMAGE_WORKFLOW, + }, + async (apiPath) => + apiPath === baseTreePath + ? { + sha: BASE_TREE_SHA, + truncated: false, + tree: [{ path: "agents", mode, type, sha: "1".repeat(40) }], + } + : request(apiPath), + ), + ).rejects.toThrow("PR base tree entry mode is invalid"); + }); + + it("rejects a manual PR catalog without a trusted pre-checkout producer", () => { + const workflow = readE2eOperationsWorkflow(); + delete workflow.jobs["generate-matrix"].outputs?.managed_image_catalog; + + expect(validateE2eOperationsWorkflow(workflow)).toContain( + "Manual PR managed-image catalog must be authenticated before candidate checkout", + ); + }); + + it("rejects a candidate mutation of the authenticated managed-image catalog", () => { + const workflow = YAML.parse(fs.readFileSync(".github/workflows/e2e.yaml", "utf8")); + const packageCli = workflow.jobs["generate-matrix"].steps.find( + (step: Record) => step.name === "Package exact-commit CLI", + ); + const stagingMatch = packageCli.run.match( + /# BEGIN exact managed-image catalog staging\n([\s\S]*?)# END exact managed-image catalog staging/u, + ); + const stagingSource = stagingMatch?.[1] ?? ""; + + const directory = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-pr-catalog-stage-test-")); + try { + fs.mkdirSync(path.join(directory, "dist"), { mode: 0o700 }); + const trustedCatalog = JSON.stringify( + Object.fromEntries( + SHIPPED_MANAGED_IMAGE_AGENTS.map((agent, index) => [agent, contract(agent, index)]), + ), + ); + const trustedDigest = createHash("sha256") + .update(`${trustedCatalog}\n`, "utf8") + .digest("hex"); + fs.writeFileSync( + path.join(directory, "pr-managed-image-catalog.json"), + '{"candidateMutation":true}\n', + { mode: 0o600 }, + ); + const scriptPath = path.join(directory, "stage-managed-image-catalog.sh"); + fs.writeFileSync(scriptPath, `set -euo pipefail\n${stagingSource}`, { mode: 0o700 }); + + const result = spawnSync("/bin/bash", ["--noprofile", "--norc", scriptPath], { + cwd: directory, + encoding: "utf8", + env: { + MANAGED_IMAGE_CATALOG: trustedCatalog, + MANAGED_IMAGE_CATALOG_SHA256: trustedDigest, + PATH: "/usr/bin:/bin:/usr/sbin:/sbin", + RUNNER_TEMP: directory, + }, + }); + + expect(result.status).not.toBe(0); + expect(result.stderr).toContain( + "trusted PR managed-image catalog changed after authentication", + ); + expect(fs.existsSync(path.join(directory, "dist/e2e-managed-image-catalog.json"))).toBe( + false, + ); + } finally { + fs.rmSync(directory, { force: true, recursive: true }); + } + }); + it("selects one successful workflow run for the candidate commit", () => { expect( selectManagedImagePublicationRun(run(), { diff --git a/tools/e2e/cli-artifact-workflow-boundary.mts b/tools/e2e/cli-artifact-workflow-boundary.mts index d3bd3032179..86cbe2158d6 100644 --- a/tools/e2e/cli-artifact-workflow-boundary.mts +++ b/tools/e2e/cli-artifact-workflow-boundary.mts @@ -201,7 +201,7 @@ export function validateCliArtifactRestoreAction( "node --input-type=module --eval", 'import { createHash } from "node:crypto"', 'import { createReadStream } from "node:fs"', - 'for await (const chunk of createReadStream(process.argv[1])) hash.update(chunk)', + "for await (const chunk of createReadStream(process.argv[1])) hash.update(chunk)", 'process.stdout.write(hash.digest("hex"))', 'lockfile_sha256="$(sha256_file package-lock.json)"', ".candidate.sha == $candidateSha", @@ -293,6 +293,9 @@ function validateProducer(errors: string[], producer: WorkflowRecord): void { !isDeepStrictEqual(record(packageStep.env), { CANDIDATE_REPOSITORY: "${{ inputs.checkout_repository || github.repository }}", CANDIDATE_SHA: "${{ inputs.checkout_sha || github.sha }}", + MANAGED_IMAGE_CATALOG: "${{ steps.resolve_pr_managed_image_catalog.outputs.catalog }}", + MANAGED_IMAGE_CATALOG_SHA256: + "${{ steps.resolve_pr_managed_image_catalog.outputs.catalog_sha256 }}", RUN_ATTEMPT: "${{ github.run_attempt }}", RUN_ID: "${{ github.run_id }}", WORKFLOW_SHA: "${{ github.workflow_sha }}", @@ -390,9 +393,7 @@ function validateConsumer( ): void { if (jobName === "mcp-bridge-dev") { const { steps: _jobSteps, ...jobExecutionContext } = job; - if ( - contentSha256(jobExecutionContext) !== MCP_DEV_JOB_EXECUTION_CONTEXT_SHA256 - ) { + if (contentSha256(jobExecutionContext) !== MCP_DEV_JOB_EXECUTION_CONTEXT_SHA256) { errors.push( "mcp-bridge-dev must preserve its reviewed job execution context before candidate activation", ); @@ -455,10 +456,7 @@ function validateConsumer( ? trustedInstallIndex : jobSteps.length - 1 : restoreIndex; - const stepsThroughSecurityBoundary = jobSteps.slice( - 0, - securityBoundaryIndex + 1, - ); + const stepsThroughSecurityBoundary = jobSteps.slice(0, securityBoundaryIndex + 1); const jobEnv = record(job.env); const defaultShell = record(record(job.defaults).run).shell; const unsafePreRestoreStep = stepsThroughSecurityBoundary.some( @@ -504,9 +502,7 @@ function validateConsumer( contentSha256(jobSteps.slice(0, trustedInstallIndex + 1)) !== MCP_DEV_TRUSTED_PREFIX_CONTENT_SHA256) ) { - errors.push( - "mcp-bridge-dev must preserve every reviewed step through trusted installation", - ); + errors.push("mcp-bridge-dev must preserve every reviewed step through trusted installation"); } if ( jobName === "mcp-bridge-dev" && @@ -538,13 +534,8 @@ function validateConsumer( const stepsBeforeRestore = jobSteps .slice(reviewedStepsStart, restoreIndex) .map((step) => step.name); - if ( - prepareIndex >= 0 && - !isDeepStrictEqual(stepsBeforeRestore, reviewedStepsBeforeRestore) - ) { - errors.push( - `${jobName} must preserve its reviewed steps through CLI artifact restore`, - ); + if (prepareIndex >= 0 && !isDeepStrictEqual(stepsBeforeRestore, reviewedStepsBeforeRestore)) { + errors.push(`${jobName} must preserve its reviewed steps through CLI artifact restore`); } } diff --git a/tools/e2e/operations-workflow-boundary.mts b/tools/e2e/operations-workflow-boundary.mts index fdd00fa79a1..ea52849fe1d 100644 --- a/tools/e2e/operations-workflow-boundary.mts +++ b/tools/e2e/operations-workflow-boundary.mts @@ -287,11 +287,15 @@ function validateManualPrDispatch(errors: string[], workflow: OperationsWorkflow (step) => step.name === "Authenticate manual PR dispatch", ); const checkoutIndex = steps.findIndex((step) => step.name === "Check out E2E candidate"); + const managedCatalogResolverIndex = steps.findIndex( + (step) => step.id === "resolve_pr_managed_image_catalog", + ); const validationIndex = steps.findIndex((step) => step.name === "Validate manual PR checkout"); const credentialAuthorizationIndex = steps.findIndex( (step) => step.name === "Authorize E2E credentials", ); const prepareIndex = steps.findIndex((step) => step.name === "Prepare E2E workspace"); + const packageIndex = steps.findIndex((step) => step.name === "Package exact-commit CLI"); if ( authenticationIndex < 0 || checkoutIndex < 0 || @@ -306,6 +310,53 @@ function validateManualPrDispatch(errors: string[], workflow: OperationsWorkflow errors.push("Manual PR authorization and validation must surround checkout before preparation"); } + const managedCatalogResolver = + managedCatalogResolverIndex >= 0 ? steps[managedCatalogResolverIndex] : {}; + if ( + matrixJob.outputs?.managed_image_catalog !== + "${{ steps.resolve_pr_managed_image_catalog.outputs.catalog }}" || + managedCatalogResolverIndex <= authenticationIndex || + managedCatalogResolverIndex >= checkoutIndex || + managedCatalogResolver.if !== + "${{ inputs.checkout_sha != '' && (inputs.jobs != 'native-runtime-qualification-producer' || inputs.targets != '') }}" || + managedCatalogResolver.shell !== "bash" || + !isDeepStrictEqual(managedCatalogResolver.env, { + BASE_SHA: "${{ inputs.base_sha }}", + CANDIDATE_REPOSITORY: "${{ inputs.checkout_repository }}", + CANDIDATE_SHA: "${{ inputs.checkout_sha }}", + GITHUB_TOKEN: "${{ github.token }}", + PR_NUMBER: "${{ inputs.pr_number }}", + }) + ) { + errors.push("Manual PR managed-image catalog must be authenticated before candidate checkout"); + } + const managedCatalogResolverSource = String(managedCatalogResolver.run ?? ""); + for (const fragment of [ + "tools/e2e/pr-managed-image-publication.mts", + "catalog=", + "catalog_sha256=", + ]) { + if (!managedCatalogResolverSource.includes(fragment)) { + errors.push(`Manual PR managed-image catalog resolver must retain ${fragment}`); + } + } + + const packageCli = packageIndex >= 0 ? steps[packageIndex] : {}; + const packageSource = String(packageCli.run ?? ""); + if ( + packageIndex <= prepareIndex || + packageCli.env?.MANAGED_IMAGE_CATALOG !== + "${{ steps.resolve_pr_managed_image_catalog.outputs.catalog }}" || + packageCli.env?.MANAGED_IMAGE_CATALOG_SHA256 !== + "${{ steps.resolve_pr_managed_image_catalog.outputs.catalog_sha256 }}" || + !packageSource.includes("# BEGIN exact managed-image catalog staging") || + !packageSource.includes("# END exact managed-image catalog staging") || + !packageSource.includes("trusted PR managed-image catalog changed after authentication") || + !packageSource.includes("packaged PR managed-image catalog does not match trusted output") + ) { + errors.push("Manual PR managed-image catalog must be sealed into the CLI artifact"); + } + const authentication = authenticationIndex >= 0 ? steps[authenticationIndex] : {}; if ( authentication.id !== "candidate_authorization" || @@ -609,12 +660,10 @@ export function validateBaseImagePublicationGate(workflow: OperationsWorkflow): outputs: { dcode_base_contract: "${{ steps.validate_dcode_base.outputs.contract }}", dcode_base_ref: "${{ steps.validate_dcode_base.outputs.base_ref }}", - managed_image_artifact_provenance: - "${{ steps.download_managed_cohort.outputs.provenance }}", + managed_image_artifact_provenance: "${{ steps.download_managed_cohort.outputs.provenance }}", managed_image_cohort: "${{ steps.validate_managed_cohort.outputs.cohort }}", managed_image_receipt: "${{ steps.validate_managed_cohort.outputs.receipt }}", - managed_image_revision: - "${{ steps.validate_managed_cohort.outputs.revision }}", + managed_image_revision: "${{ steps.validate_managed_cohort.outputs.revision }}", managed_image_run_attempt: "${{ steps.validate_managed_cohort.outputs.run_attempt }}", managed_image_run_id: "${{ steps.validate_managed_cohort.outputs.run_id }}", }, @@ -754,7 +803,7 @@ export function validateBaseImagePublicationGate(workflow: OperationsWorkflow): } if ( live.env?.E2E_MANAGED_IMAGE_REVISION !== - "${{ needs.generate-matrix.outputs.managed_image_catalog == '' && needs.base-image-publication.outputs.managed_image_revision || '' }}" + "${{ needs.generate-matrix.outputs.managed_image_catalog == '' && needs.base-image-publication.outputs.managed_image_revision || '' }}" ) { errors.push( "live stock onboarding must use the selected managed-image revision when no exact PR catalog is present", @@ -788,7 +837,7 @@ export function validateBaseImagePublicationGate(workflow: OperationsWorkflow): } if ( live.env?.NEMOCLAW_LANGCHAIN_DEEPAGENTS_CODE_SANDBOX_BASE_IMAGE_REF !== - "${{ needs.base-image-publication.outputs.dcode_base_ref }}" + "${{ needs.base-image-publication.outputs.dcode_base_ref }}" ) { errors.push("live DCode must use the selected immutable base reference"); } @@ -850,9 +899,7 @@ const MANAGED_IMAGE_RECEIPT_EXPRESSION = "${{ needs.generate-matrix.outputs.managed_image_catalog == '' && needs.base-image-publication.outputs.managed_image_receipt || '' }}"; /** Require publication success and one exact cohort revision for every stock onboarding job. */ -export function validateStockOnboardingPublicationBoundary( - workflow: OperationsWorkflow, -): string[] { +export function validateStockOnboardingPublicationBoundary(workflow: OperationsWorkflow): string[] { const errors: string[] = []; for (const jobName of STOCK_ONBOARDING_JOBS) { const job = workflow.jobs[jobName] ?? {}; diff --git a/tools/e2e/pr-managed-image-publication.mts b/tools/e2e/pr-managed-image-publication.mts index 0c7e0b4e09f..a187981050a 100644 --- a/tools/e2e/pr-managed-image-publication.mts +++ b/tools/e2e/pr-managed-image-publication.mts @@ -26,9 +26,14 @@ const WORKFLOW_PATH = ".github/workflows/managed-images.yaml"; const WORKFLOW_FILE = "managed-images.yaml"; const WORKFLOW_NAME = "Images / Build, Test, and Publish Managed Images"; const MAX_CHANGED_FILES = 3_000; -const PAGE_SIZE = 100; +const MAX_COMMIT_TREE_ENTRIES = 100_000; const SHA_PATTERN = /^[0-9a-f]{40}$/u; const SAFE_PATH_PATTERN = /^[A-Za-z0-9._/*-]+$/u; +const TREE_ENTRY_MODES = new Map([ + ["blob", new Set(["100644", "100755", "120000"])], + ["commit", new Set(["160000"])], + ["tree", new Set(["040000"])], +]); type JsonRecord = Record; @@ -56,6 +61,13 @@ function exactString(value: unknown, expected: string, label: string): void { if (value !== expected) throw new Error(`${label} must be ${expected}`); } +function sha(value: unknown, label: string): string { + if (typeof value !== "string" || !SHA_PATTERN.test(value)) { + throw new Error(`${label} must be a lowercase 40-character SHA`); + } + return value; +} + function compileManagedImagePath(pattern: string): RegExp { if ( !SAFE_PATH_PATTERN.test(pattern) || @@ -241,49 +253,71 @@ function validateWorkflow(payload: unknown): number { return id; } -async function readChangedFiles( - prNumber: number, - count: number, +async function readCommitTree( + revision: string, + label: string, request: (path: string) => Promise, -): Promise { - if (!Number.isSafeInteger(count) || count < 0 || count > MAX_CHANGED_FILES) { - throw new Error("PR changed-file count is invalid"); +): Promise> { + const commit = record( + await request(`/repos/${REPOSITORY}/git/commits/${revision}`), + `${label} commit`, + ); + exactString(commit.sha, revision, `${label} commit SHA`); + const treeSha = sha(record(commit.tree, `${label} commit tree`).sha, `${label} tree SHA`); + const payload = record( + await request(`/repos/${REPOSITORY}/git/trees/${treeSha}?recursive=1`), + `${label} tree`, + ); + exactString(payload.sha, treeSha, `${label} tree SHA`); + if (payload.truncated !== false) { + throw new Error(`${label} commit tree is truncated`); } - const files: string[] = []; - let listedFiles = 0; - for (let page = 1; listedFiles < count; page += 1) { - if (page > Math.ceil(MAX_CHANGED_FILES / PAGE_SIZE)) { - throw new Error("PR changed-file pagination exceeded the safety cap"); + if (!Array.isArray(payload.tree) || payload.tree.length > MAX_COMMIT_TREE_ENTRIES) { + throw new Error(`${label} commit tree is invalid or exceeds the entry limit`); + } + + const entries = new Map(); + const paths = new Set(); + for (const value of payload.tree) { + const entry = record(value, `${label} tree entry`); + if (typeof entry.path !== "string" || entry.path.length === 0) { + throw new Error(`${label} tree entry path is invalid`); } - const payload = await request( - `/repos/${REPOSITORY}/pulls/${prNumber}/files?per_page=${PAGE_SIZE}&page=${page}`, - ); - if (!Array.isArray(payload) || payload.length === 0 || payload.length > PAGE_SIZE) { - throw new Error("PR changed-file page is invalid or incomplete"); + if (paths.has(entry.path)) throw new Error(`${label} commit tree contains duplicate paths`); + paths.add(entry.path); + const validModes = + typeof entry.type === "string" ? TREE_ENTRY_MODES.get(entry.type) : undefined; + if (!validModes) { + throw new Error(`${label} tree entry type is invalid`); } - for (const value of payload) { - const file = record(value, "PR changed file"); - if (typeof file.filename !== "string") throw new Error("PR changed-file name is invalid"); - files.push(file.filename); - if (file.previous_filename !== undefined) { - if (typeof file.previous_filename !== "string") { - throw new Error("PR previous changed-file name is invalid"); - } - files.push(file.previous_filename); - } - listedFiles += 1; + if (typeof entry.mode !== "string" || !validModes.has(entry.mode)) { + throw new Error(`${label} tree entry mode is invalid`); } + const entrySha = sha(entry.sha, `${label} tree entry SHA`); + if (entry.type === "tree") continue; + entries.set(entry.path, `${entry.mode}:${entry.type}:${entrySha}`); } - if (listedFiles !== count) { - throw new Error("PR changed-file listing is incomplete"); + return entries; +} + +async function readChangedFiles( + input: { readonly baseSha: string; readonly candidateSha: string }, + request: (path: string) => Promise, +): Promise { + const baseTree = await readCommitTree(input.baseSha, "PR base", request); + const candidateTree = await readCommitTree(input.candidateSha, "PR candidate", request); + const changedFiles: string[] = []; + for (const changedPath of new Set([...baseTree.keys(), ...candidateTree.keys()])) { + if (baseTree.get(changedPath) === candidateTree.get(changedPath)) continue; + changedFiles.push(changedPath); } - return [...new Set(files)]; + return changedFiles; } function validatePr( payload: unknown, expected: { readonly baseSha: string; readonly candidateSha: string; readonly prNumber: number }, -): number { +): void { const pull = record(payload, "pull request"); exactString(pull.state, "open", "pull request state"); exactString( @@ -307,7 +341,6 @@ function validatePr( REPOSITORY, "pull request source repository", ); - return positiveInteger(pull.changed_files, "PR changed-file count"); } /** Resolve and download the exact all-agent catalog before candidate code executes. */ @@ -329,11 +362,8 @@ export async function resolvePrManagedImageCatalog( } positiveInteger(input.prNumber, "PR number"); if (!input.token) throw new Error("GITHUB_TOKEN is required"); - const changedCount = validatePr( - await request(`/repos/${REPOSITORY}/pulls/${input.prNumber}`), - input, - ); - const changedFiles = await readChangedFiles(input.prNumber, changedCount, request); + validatePr(await request(`/repos/${REPOSITORY}/pulls/${input.prNumber}`), input); + const changedFiles = await readChangedFiles(input, request); const patterns = parseManagedImagePullRequestPaths(input.workflowSource); if (!managedImagePublicationRequired(changedFiles, patterns)) return "not-required";