From 532d7fdda089dd00dbcf04303a32a9006e8de0c0 Mon Sep 17 00:00:00 2001 From: Carlos Villela Date: Wed, 15 Jul 2026 01:18:10 -0700 Subject: [PATCH 1/4] fix(ci): publish PR E2E gate as native job Signed-off-by: Carlos Villela --- .github/workflows/pr-e2e-gate.yaml | 37 +++ test/e2e/README.md | 164 ++++++++------ test/e2e/docs/README.md | 23 +- test/helpers/vitest-watch-triggers.ts | 4 + test/pr-e2e-gate-fork-skip.test.ts | 2 +- test/pr-e2e-gate-lifecycle.test.ts | 2 +- test/pr-e2e-gate-workflow.test.ts | 33 ++- test/pr-e2e-gate.test.ts | 4 +- test/pr-e2e-required.test.ts | 196 ++++++++++++++++ test/pr-risk-plan.test.ts | 7 +- test/vitest-watch-triggers.test.ts | 5 + tools/advisors/risk-plan.mts | 1 + tools/e2e/pr-e2e-gate.mts | 2 +- tools/e2e/pr-e2e-required.mts | 312 ++++++++++++++++++++++++++ 14 files changed, 714 insertions(+), 78 deletions(-) create mode 100644 test/pr-e2e-required.test.ts create mode 100644 tools/e2e/pr-e2e-required.mts diff --git a/.github/workflows/pr-e2e-gate.yaml b/.github/workflows/pr-e2e-gate.yaml index 9ff02d7977b..06480f3cc58 100644 --- a/.github/workflows/pr-e2e-gate.yaml +++ b/.github/workflows/pr-e2e-gate.yaml @@ -83,6 +83,43 @@ jobs: --head "$HEAD_SHA" --base "$BASE_SHA" + required: + name: E2E / PR Gate + if: ${{ github.event_name == 'pull_request_target' && github.repository == 'NVIDIA/NemoClaw' && github.event.action != 'closed' }} + runs-on: ubuntu-latest + timeout-minutes: 170 + permissions: + checks: read + contents: read + pull-requests: read + concurrency: + group: pr-e2e-required-${{ github.event.pull_request.number }} + cancel-in-progress: true + steps: + - name: Checkout observer + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + with: + ref: ${{ github.workflow_sha }} + persist-credentials: false + + - name: Setup Node + uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.0.0 + with: + node-version: "22" + + - name: Wait for trusted exact-diff verdict + env: + BASE_SHA: ${{ github.event.pull_request.base.sha }} + GITHUB_TOKEN: ${{ github.token }} + HEAD_SHA: ${{ github.event.pull_request.head.sha }} + PR_NUMBER: ${{ github.event.pull_request.number }} + run: >- + node --experimental-strip-types tools/e2e/pr-e2e-required.mts + --pr "$PR_NUMBER" + --head "$HEAD_SHA" + --base "$BASE_SHA" + --timeout-seconds 9900 + cancel-superseded: if: ${{ github.event_name == 'pull_request_target' && github.repository == 'NVIDIA/NemoClaw' && github.event.pull_request.head.repo.full_name == github.repository && (github.event.action != 'edited' || github.event.changes.base != null) }} runs-on: ubuntu-latest diff --git a/test/e2e/README.md b/test/e2e/README.md index cbdef1d8458..d965ad273a1 100644 --- a/test/e2e/README.md +++ b/test/e2e/README.md @@ -11,7 +11,9 @@ before those targets run; local runners must provide it themselves. - `.github/workflows/e2e.yaml` is the scheduled, manually dispatchable, and selectively dispatched live target workflow. - `.github/workflows/pr-e2e-gate.yaml` runs as `E2E / PR Gate Controller` and - publishes the exact-diff `E2E / PR Gate` check. + publishes the trusted exact-diff `E2E / PR Gate Coordination` check and the + native `E2E / PR Gate` job that mirrors coordination into the PR's required + GitHub Actions check suite. - `.github/workflows/e2e-branch-validation.yaml` provisions Brev instances and runs focused E2E targets from source on a clean machine. - Platform workflows such as macOS, WSL, sandbox image, and regression E2E @@ -83,21 +85,36 @@ artifact so baseline aggregation stays stable. Older issue references to Vitest target artifacts under `e2e-artifacts/vitest/` map to this consolidated `e2e-artifacts/live/` registry-target artifact layout. -## PR E2E check - -The workflow and custom check deliberately use different names and report -different results. `E2E / PR Gate Controller` reports whether the trusted -controller ran and published a terminal result. `E2E / PR Gate` is the -exact-diff merge verdict. A handled prerequisite-CI failure, selected E2E -failure or timeout, stale revision, or closed PR can leave the controller green -while the custom check is failed or cancelled. Only a successful `E2E / PR Gate` -for the current head and base satisfies the gate. An unexpected controller -error still fails the controller workflow and fails the custom check closed. +## PR E2E gate + +The controller, coordination check, and required job deliberately use +different names and report different parts of the lifecycle. +`E2E / PR Gate Controller` reports whether the trusted controller handled its +event. The controller publishes the internal custom check +`E2E / PR Gate Coordination` as its exact-diff verdict. +The default-branch `pull_request_target` path publishes the native GitHub +Actions job named `E2E / PR Gate`. It checks out the controller at +`github.workflow_sha`, validates that the PR still has the observed head and +base, waits for the matching trusted coordination identity, and exits with its +terminal verdict. It also writes that verdict and the trusted run link to the +job summary. During rollout, the observer accepts the former custom-check name +`E2E / PR Gate` for the same exact-diff external identity so in-flight PRs do +not lose their gate. + +A handled prerequisite-CI failure, selected E2E failure or timeout, stale +revision, or closed PR can leave the controller green while coordination is +failed or cancelled and the native job is non-passing. Only a successful native +`E2E / PR Gate` for the current head and base satisfies the required check. An +unexpected controller error still fails the controller workflow and fails +coordination closed, which prevents the native job from passing. On open, synchronization, reopen, transition out of draft, or base retarget, -`.github/workflows/pr-e2e-gate.yaml` reserves `E2E / PR Gate` for the exact PR -head and base commits, including fork heads. A base retarget fails any earlier -gate in that head's lineage before reserving the new exact-diff gate. The +`.github/workflows/pr-e2e-gate.yaml` reserves `E2E / PR Gate Coordination` for +the exact PR head and base commits, including fork heads. The read-only native +observer starts for every configured non-closed PR event; metadata-only edits +mirror the existing exact-diff coordination result instead of publishing a +skipped success. A base retarget fails any earlier coordination result in that +head's lineage before reserving the new exact-diff identity. The `CI / Pull Request` run name binds its PR number, head SHA, base SHA, and gate eligibility so the trusted controller can authenticate the completed run even when a fork `workflow_run` payload omits pull-request metadata. The controller @@ -112,14 +129,16 @@ Runtime families and changes to workflow-wired live tests select canonical selectors from the trusted `e2e.yaml` inventory independently of advisor output. Ordinary internal changes execute those focused selections. Gate initialization and CI coordination share one non-cancelling concurrency -group for the head repository and branch. Before either path creates a check or -applies a result to the current revision, it reads the live PR and requires the -event's exact head and base, including when PR CI failed. This keeps a stale seed -or completed CI run from being applied to a newer exact diff. A completed CI +group for the head repository and branch. Before the controller creates or +updates coordination for the current revision, it reads the live PR and +requires the event's exact head and base, including when PR CI failed. The +native observer performs the same live exact-revision check before waiting and +again before accepting a terminal verdict. This keeps a stale seed, completed +CI run, or observer from being applied to a newer exact diff. A completed CI event for an older revision is handled without creating or updating the current -revision's check. -If the older revision still has an in-progress check, the controller completes -it as cancelled with `Superseded by PR update` or +revision's coordination check. +If the older revision still has an in-progress coordination check, the +controller completes it as cancelled with `Superseded by PR update` or `PR closed — gate no longer applies` and identifies the obsolete head and base. The closed-PR outcome also applies when a fork repository was deleted and GitHub consequently returns no head-repository object. @@ -129,12 +148,14 @@ Shared sandbox-boundary changes have a floor of `full-e2e`, `hermes-e2e`, and family is a conservative path boundary that includes non-documentation files under `tools/e2e/` and `test/e2e/`, plus the E2E and PR-CI workflows, risk policy, dependency and test configuration, and preparation and upload actions. -An internal revision whose matched control-plane files are drawn only from -`.github/workflows/pr-e2e-gate.yaml` and `tools/e2e/pr-e2e-gate.mts` -automatically dispatches those selected jobs. Any other or mixed internal -control-plane revision requires the exact-SHA maintainer authorization below -before credentialed execution begins. If no job is selected, the check passes -without an E2E run. +An internal revision whose matched control-plane files are drawn only from the +trusted controller and observer boundaries—`.github/workflows/pr-e2e-gate.yaml`, +`tools/e2e/pr-e2e-gate.mts`, and `tools/e2e/pr-e2e-required.mts`—automatically +dispatches those selected jobs. +Any other or mixed internal control-plane revision requires the exact-SHA +maintainer authorization below before credentialed execution begins. If no job +is selected, coordination passes without an E2E run and the native required job +mirrors that success. Before dispatch, the controller verifies that the live PR still matches the CI run's exact head and base. It uses its own workflow commit when that commit is @@ -152,32 +173,39 @@ belongs to `NVIDIA/NemoClaw`, and still has both the dispatched head and base commits. The dispatch includes selected jobs and valid plan and correlation metadata, but not `targets`. The controller uses GitHub's returned run ID for waiting, evidence download, and completion, then revalidates that the PR is -still open with the live head, base, and exact-diff check identity before -recording a final result. - -An internal revision whose control-plane matches include a file outside the two -trusted controller files completes the exact-diff gate as failed with -`Maintainer authorization required to run E2E`. No selected job runs and no -repository secret is exposed. After reviewing the exact revision, a repository +still open with the live head, base, and exact-diff coordination identity before +recording a final result. The native observer revalidates the live revision +before mirroring that terminal result. + +An internal revision whose control-plane matches include a file outside the +trusted controller and observer boundaries completes coordination as failed +with `Maintainer authorization required to run E2E`. The native required job +keeps waiting for the authorization flow. No selected job runs and no repository +secret is exposed. After reviewing the exact revision, a repository maintainer or administrator chooses **Run workflow** on `main`, selects `run-control-plane`, and supplies the PR number, current 40-character head SHA as `expected_head_sha`, current 40-character base SHA as `expected_base_sha`, and a specific 10–500-character `review_reason`. The authorization requires the first workflow attempt and revalidates the actor's `maintain` or `admin` permission, internal repository origin, open PR, exact head and base, risk -plan, matching failed gate, compatible trusted controller commit, and final -live revision. It then returns the same gate to in progress and dispatches the -selected jobs. The normal wait, evidence download, and finish path is the only -path that can record success; the authorization itself cannot make the gate -green. A changed head or base requires a new authorization. - -A fork revision that selects jobs completes the exact-diff gate as failed -without dispatching the selected credential-bearing jobs or exposing -repository secrets. Non-secret PR CI remains required. The failed check summary +plan, matching failed coordination check, compatible trusted controller commit, +and final live revision. It then returns coordination to in progress and +dispatches the selected jobs. The native required job treats this authorization +title as an intermediate waiting state instead of mirroring the temporary +failure. The normal wait, evidence download, and finish path is the only path +that can record success; the authorization itself cannot make the gate green. +A changed head or base requires a new authorization. + +A fork revision that selects jobs completes coordination as failed while the +native required job waits for the skip-approval flow. The controller does not +dispatch the selected credential-bearing jobs or expose repository secrets. +Non-secret PR CI remains required. The failed coordination summary embeds an explicit link to the same `E2E / PR Gate Controller` run; maintainers -follow that link rather than relying on the custom check's **Details** -destination. The check publishes only allowlisted skip-approval metadata for -its PR number, mode, head SHA, and base SHA. That controller run starts +follow that link rather than relying on the coordination check's **Details** +destination. The coordination check publishes only allowlisted skip-approval +metadata for its PR number, mode, head SHA, and base SHA. The native required +job recognizes the approval-required title as an intermediate waiting state. +That controller run starts `Approve credentialed E2E skip for fork PR`, which waits on the protected `approve-credentialed-e2e-skip-for-fork-pr` environment. With `deployment: false`, the job does not create a deployment record. A maintainer @@ -207,14 +235,16 @@ of this exact workflow on `main`, at the trusted workflow SHA and with the the exact environment, then verifies that the recorded reviewer still has repository `maintain` or `admin` permission. The shared resolver revalidates the open PR, repository origin, exact head and base SHAs, deterministic plan, -matching failed gate, and that the controller commit is either still `main` or +matching failed coordination check, and that the controller commit is either +still `main` or has only a compatible safe descendant as described above. Immediately before recording success, it reads the live PR again and requires the same exact head and base. The result records the reviewer, bounded optional comment, validated approval-run URL, plan hash, and jobs that did not run. The successful skip -check is titled `Credentialed E2E skipped for fork PR — approved by +coordination check is titled `Credentialed E2E skipped for fork PR — approved by @` and begins with `Outcome: APPROVED SKIP — credentialed E2E did -not run.` It never claims that the selected jobs passed. +not run.` It never claims that the selected jobs passed. The native required +job mirrors this approved-skip success. The manual fork skip approval on `main` remains available as a fallback. Choose `approve-fork-e2e-skip` and provide the PR number, current `expected_head_sha`, @@ -240,33 +270,41 @@ signal travels in the selected job's existing E2E artifact. Its private dispatch state is protected by a SHA-256 digest that is verified before downloaded evidence is classified. -When the plan selects jobs, the check passes only when the E2E run succeeds and -every expected job shard uploads one complete passing signal with no skips or -pending tests. For the current exact diff, every other dispatched outcome -fails. A failed result links the selected E2E run and up to 10 non-passing jobs, -including up to three failed step names per job. If GitHub truncates the job -listing or the controller cannot load it, the check directs the maintainer to -the complete run. +When the plan selects jobs, coordination passes only when the E2E run succeeds +and every expected job shard uploads one complete passing signal with no skips +or pending tests. The native required job passes only after observing that +trusted success. For the current exact diff, every other dispatched outcome +fails. A failed coordination result links the selected E2E run and up to 10 +non-passing jobs, including up to three failed step names per job. If GitHub +truncates the job listing or the controller cannot load it, the coordination +check directs the maintainer to the complete run. The coordinator has a 180-minute job budget and gives the selected E2E run 105 minutes to finish. When that limit expires, finalization cancels the child and -records the non-passing result in the custom check. Evidence download has its -own 10-minute limit. If the selected child succeeds but the `Download evidence` -step fails, is cancelled, or is skipped, the controller cannot authenticate the -child's artifacts. It fails the custom check closed as +records the non-passing result in the coordination check. The native observer +has a 170-minute job budget and waits up to 165 minutes for a trusted terminal +verdict. Evidence download has its own 10-minute limit. If the selected child +succeeds but the `Download evidence` step fails, is cancelled, or is skipped, +the controller cannot authenticate the child's artifacts. It fails +coordination closed as `Evidence could not be verified` and leaves `E2E / PR Gate Controller` red so maintainers inspect that infrastructure failure and rerun the gate. If the download step succeeds but signals are missing, duplicated, skipped, pending, or report a test failure, the controller has completed its work: it publishes the handled red PR verdict and remains green. Malformed or unsafe evidence, schema or exact-identity mismatches, and traversal-limit violations remain -controller verification errors, so both the check and controller fail closed. +controller verification errors, so coordination, the native required job, and +the controller fail closed. These dispatches suppress PR comments and the scheduled or manual scorecard, including scorecard Slack reporting. Synchronizing, reopening, or closing an internal PR cancels its active E2E runs. A new dispatch also cancels the previous run. The previous controller -then completes the old exact-diff check as cancelled when the PR revision moved -or closed, or as failed when the current revision's selected E2E did not pass. +then completes the old exact-diff coordination check as cancelled when the PR +revision moved or closed, or as failed when the current revision's selected E2E +did not pass. Native observer concurrency cancels the old required-job run and +starts a new one when a configured non-closed PR event identifies the current +revision. Metadata-only edits restart the observer against the unchanged +exact-diff identity. The controller does not read PR Review Advisor output, so model availability and recommendations are not part of merge authority. diff --git a/test/e2e/docs/README.md b/test/e2e/docs/README.md index 9465572d58f..4341e263fbb 100644 --- a/test/e2e/docs/README.md +++ b/test/e2e/docs/README.md @@ -115,16 +115,25 @@ test/e2e/ ledger. The advisor uses it as recommendation context, while the controller applies it independently without model output. -- `.github/workflows/pr-e2e-gate.yaml` reserves `E2E / PR Gate` on every exact - PR head, including forks, before `CI / Pull Request` completes. The trusted +- `.github/workflows/pr-e2e-gate.yaml` reserves the internal + `E2E / PR Gate Coordination` check on every exact PR head, including forks, + before `CI / Pull Request` completes. Its default-branch + `pull_request_target` path also publishes the native GitHub Actions job named + `E2E / PR Gate`. The read-only observer runs from `github.workflow_sha`, + validates the live PR head and base, waits for the matching trusted + coordination identity, and mirrors the terminal verdict into the required + job and its summary. Authorization states remain pending while the maintainer + decision is recorded. During rollout, the observer also accepts the former + `E2E / PR Gate` custom-check name for the same exact-diff identity. The controller builds the risk plan from GitHub's complete file list. Internal revisions normally dispatch every selected job and verify each expected `risk-signal.json`; this remains automatic when their `e2e-control-plane` - matches are drawn only from the trusted controller workflow and script. - Other or mixed internal control-plane revisions require a maintainer-authorized - exact-SHA run; only its verified evidence can pass the gate. Risky forks - retain the audited credentialed-E2E skip approval. See - [NemoClaw E2E CI](../README.md) for the full lifecycle. + matches are drawn only from the trusted controller workflow and scripts. + Other or mixed internal + control-plane revisions require a maintainer-authorized exact-SHA run; only + its verified evidence can pass coordination. Risky forks retain the audited + credentialed-E2E skip approval. See [NemoClaw E2E CI](../README.md) for the + full lifecycle. - `.github/workflows/e2e.yaml` runs selected or all supported live E2E targets and uploads an explicit artifact allowlist with diff --git a/test/helpers/vitest-watch-triggers.ts b/test/helpers/vitest-watch-triggers.ts index 2f81e424730..b4b7fea2db9 100644 --- a/test/helpers/vitest-watch-triggers.ts +++ b/test/helpers/vitest-watch-triggers.ts @@ -87,6 +87,10 @@ export const vitestWatchTriggerPatterns: VitestWatchTriggerPattern[] = [ pattern: /(?:^|\/)\.github\/workflows\/e2e\.yaml$/, testsToRun: runTests(...E2E_WORKFLOW_CONTRACTS), }, + { + pattern: /(?:^|\/)\.github\/workflows\/pr-e2e-gate\.yaml$/, + testsToRun: runTests("test/pr-e2e-gate-workflow.test.ts", "test/pr-e2e-required.test.ts"), + }, { pattern: /(?:^|\/)(?:\.github\/workflows\/platform-vitest-main\.yaml|ci\/platform-vitest-macos-requirements\.lock)$/, diff --git a/test/pr-e2e-gate-fork-skip.test.ts b/test/pr-e2e-gate-fork-skip.test.ts index f20e04edad4..5935e0d3f5f 100644 --- a/test/pr-e2e-gate-fork-skip.test.ts +++ b/test/pr-e2e-gate-fork-skip.test.ts @@ -54,7 +54,7 @@ function emptyPrGateCheckRunsRoute() { function exactPrGateCheck(overrides: Record = {}) { return { id: 17, - name: "E2E / PR Gate", + name: "E2E / PR Gate Coordination", head_sha: HEAD_SHA, external_id: prGateExternalId(42, HEAD_SHA, BASE_SHA), status: "in_progress", diff --git a/test/pr-e2e-gate-lifecycle.test.ts b/test/pr-e2e-gate-lifecycle.test.ts index 326445cce7a..8b3f84ae600 100644 --- a/test/pr-e2e-gate-lifecycle.test.ts +++ b/test/pr-e2e-gate-lifecycle.test.ts @@ -58,7 +58,7 @@ function emptyPrGateCheckRunsRoute() { function exactPrGateCheck(overrides: Record = {}) { return { id: 17, - name: "E2E / PR Gate", + name: "E2E / PR Gate Coordination", head_sha: HEAD_SHA, external_id: prGateExternalId(42, HEAD_SHA, BASE_SHA), status: "in_progress", diff --git a/test/pr-e2e-gate-workflow.test.ts b/test/pr-e2e-gate-workflow.test.ts index acfab5c2009..a1ceb96cf33 100644 --- a/test/pr-e2e-gate-workflow.test.ts +++ b/test/pr-e2e-gate-workflow.test.ts @@ -358,6 +358,7 @@ describe("PR E2E gate workflow", () => { it("limits triggers and job permissions", () => { const workflow = readYaml(PR_GATE_PATH); const initialize = workflow.jobs.initialize; + const required = workflow.jobs.required; const cancel = workflow.jobs["cancel-superseded"]; const coordinate = workflow.jobs.coordinate; const approveForkSkip = workflow.jobs["approve-fork-e2e-skip"]; @@ -425,6 +426,34 @@ describe("PR E2E gate workflow", () => { expect(initialize.concurrency?.group).toBe( "pr-e2e-gate-${{ github.event.pull_request.head.repo.full_name }}-${{ github.event.pull_request.head.ref }}", ); + expect(required.name).toBe("E2E / PR Gate"); + expect(required.if).toContain("github.event_name == 'pull_request_target'"); + expect(required.if).toContain("github.event.action != 'closed'"); + expect(required.permissions).toEqual({ + checks: "read", + contents: "read", + "pull-requests": "read", + }); + expect(required.concurrency).toEqual({ + group: "pr-e2e-required-${{ github.event.pull_request.number }}", + "cancel-in-progress": true, + }); + expect(required["timeout-minutes"]).toBe(170); + expect(required.secrets).toBeUndefined(); + expect(step(required, "Checkout observer").with).toEqual({ + ref: "${{ github.workflow_sha }}", + "persist-credentials": false, + }); + const observer = step(required, "Wait for trusted exact-diff verdict"); + expect(observer.env).toEqual({ + BASE_SHA: "${{ github.event.pull_request.base.sha }}", + GITHUB_TOKEN: "${{ github.token }}", + HEAD_SHA: "${{ github.event.pull_request.head.sha }}", + PR_NUMBER: "${{ github.event.pull_request.number }}", + }); + expect(observer.run).toContain("tools/e2e/pr-e2e-required.mts"); + expect(observer.run).toContain('--head "$HEAD_SHA"'); + expect(observer.run).toContain('--base "$BASE_SHA"'); expect(cancel.if).toContain("github.event_name == 'pull_request_target'"); expect(cancel.if).toContain( "github.event.pull_request.head.repo.full_name == github.repository", @@ -555,7 +584,7 @@ describe("PR E2E gate workflow", () => { (candidate) => candidate.name === "Install controller dependencies", ); - expect(checkouts).toHaveLength(5); + expect(checkouts).toHaveLength(6); expect( checkouts.every( (checkout) => @@ -563,7 +592,7 @@ describe("PR E2E gate workflow", () => { checkout.with?.["persist-credentials"] === false, ), ).toBe(true); - expect(nodeSetups).toHaveLength(5); + expect(nodeSetups).toHaveLength(6); expect(nodeSetups.every((setup) => setup.with?.["node-version"] === "22")).toBe(true); expect(nodeSetups.every((setup) => !("cache" in (setup.with ?? {})))).toBe(true); expect(installs).toHaveLength(5); diff --git a/test/pr-e2e-gate.test.ts b/test/pr-e2e-gate.test.ts index 489651dfc04..70fd9071b28 100644 --- a/test/pr-e2e-gate.test.ts +++ b/test/pr-e2e-gate.test.ts @@ -91,7 +91,7 @@ function emptyPrGateCheckRunsRoute() { function exactPrGateCheck(overrides: Record = {}) { return { id: 17, - name: "E2E / PR Gate", + name: "E2E / PR Gate Coordination", head_sha: HEAD_SHA, external_id: prGateExternalId(42, HEAD_SHA, BASE_SHA), status: "in_progress", @@ -1349,7 +1349,7 @@ describe("PR E2E controller", () => { (request) => request.url.endsWith("/check-runs") && request.method === "POST", ); expect(checkCreation?.body).toMatchObject({ - name: "E2E / PR Gate", + name: "E2E / PR Gate Coordination", head_sha: HEAD_SHA, external_id: prGateExternalId(42, HEAD_SHA, BASE_SHA), status: "in_progress", diff --git a/test/pr-e2e-required.test.ts b/test/pr-e2e-required.test.ts new file mode 100644 index 00000000000..a677a0e7892 --- /dev/null +++ b/test/pr-e2e-required.test.ts @@ -0,0 +1,196 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { spawnSync } from "node:child_process"; +import { fileURLToPath } from "node:url"; + +import { afterEach, describe, expect, it, vi } from "vitest"; +import { + type CoordinationCheckRun, + classifyCoordinationCheck, + coordinationExternalId, + findCoordinationCheck, + type RequiredGateIdentity, + waitForRequiredGate, +} from "../tools/e2e/pr-e2e-required.mts"; + +const HEAD_SHA = "a".repeat(40); +const BASE_SHA = "b".repeat(40); +const SCRIPT = fileURLToPath(new URL("../tools/e2e/pr-e2e-required.mts", import.meta.url)); + +const identity: RequiredGateIdentity = { + repository: "NVIDIA/NemoClaw", + token: "token", + prNumber: 42, + headSha: HEAD_SHA, + baseSha: BASE_SHA, +}; + +afterEach(() => { + vi.restoreAllMocks(); +}); + +function githubResponse(value: unknown, status = 200): Response { + return { + ok: status >= 200 && status < 300, + status, + text: async () => JSON.stringify(value), + } as Response; +} + +function pullRequest(overrides: Record = {}) { + return { + number: 42, + state: "open", + head: { sha: HEAD_SHA }, + base: { sha: BASE_SHA }, + ...overrides, + }; +} + +function check( + name = "E2E / PR Gate Coordination", + overrides: Partial = {}, +): CoordinationCheckRun { + return { + id: 17, + name, + head_sha: HEAD_SHA, + external_id: coordinationExternalId(42, HEAD_SHA, BASE_SHA), + status: "completed", + conclusion: "success", + details_url: "https://github.com/NVIDIA/NemoClaw/actions/runs/99", + output: { title: "All selected E2E jobs passed" }, + app: { id: 15368 }, + ...overrides, + }; +} + +function listing(checks: CoordinationCheckRun[]) { + return { total_count: checks.length, check_runs: checks }; +} + +describe("native PR E2E required job", () => { + it("loads under the workflow Node runtime", () => { + const result = spawnSync( + process.execPath, + ["--experimental-strip-types", SCRIPT, "--pr", "42"], + { encoding: "utf8" }, + ); + + expect(result.status).toBe(1); + expect(result.stderr).toContain("--head is required"); + expect(result.stderr).not.toContain("ERR_UNSUPPORTED_TYPESCRIPT_SYNTAX"); + }); + + it("classifies authorization-required failures as pending", () => { + expect( + classifyCoordinationCheck( + check("E2E / PR Gate", { + conclusion: "failure", + output: { title: "Maintainer authorization required to run E2E" }, + }), + ), + ).toEqual({ + state: "waiting", + description: "Maintainer authorization required to run E2E", + }); + }); + + it("returns terminal trusted conclusions and drops untrusted detail links", () => { + expect( + classifyCoordinationCheck( + check(undefined, { + conclusion: "failure", + details_url: "https://example.com/untrusted", + output: { title: "Selected E2E jobs failed" }, + }), + ), + ).toEqual({ + state: "complete", + result: { conclusion: "failure", title: "Selected E2E jobs failed" }, + }); + }); + + it("prefers the renamed coordination check without querying the legacy name", async () => { + const urls: string[] = []; + vi.spyOn(globalThis, "fetch").mockImplementation(async (input) => { + const url = String(input); + urls.push(url); + return githubResponse(listing([check()])); + }); + + await expect(findCoordinationCheck(identity)).resolves.toMatchObject({ id: 17 }); + expect(urls).toHaveLength(1); + expect(urls[0]).toContain("E2E%20%2F%20PR%20Gate%20Coordination"); + }); + + it("uses the old required-name check during the native-job migration", async () => { + vi.spyOn(globalThis, "fetch").mockImplementation(async (input) => { + const url = String(input); + return githubResponse( + url.includes("Coordination") ? listing([]) : listing([check("E2E / PR Gate")]), + ); + }); + + await expect(findCoordinationCheck(identity)).resolves.toMatchObject({ + name: "E2E / PR Gate", + }); + }); + + it("rejects an exact-diff identity claimed by another app", async () => { + vi.spyOn(globalThis, "fetch").mockResolvedValue( + githubResponse(listing([check(undefined, { app: { id: 1 } })])), + ); + + await expect(findCoordinationCheck(identity)).rejects.toThrow("unexpected GitHub App"); + }); + + it("waits through authorization and revalidates the exact PR before passing", async () => { + let legacyQueries = 0; + let clock = 0; + vi.spyOn(globalThis, "fetch").mockImplementation(async (input) => { + const url = String(input); + if (url.includes("/pulls/42")) return githubResponse(pullRequest()); + if (url.includes("Coordination")) return githubResponse(listing([])); + legacyQueries += 1; + return githubResponse( + listing([ + legacyQueries === 1 + ? check("E2E / PR Gate", { + conclusion: "failure", + output: { title: "Maintainer authorization required to run E2E" }, + }) + : check("E2E / PR Gate"), + ]), + ); + }); + + await expect( + waitForRequiredGate(identity, { + timeoutMs: 100, + pollIntervalMs: 10, + now: () => clock, + sleep: async (milliseconds) => { + clock += milliseconds; + }, + }), + ).resolves.toMatchObject({ conclusion: "success" }); + expect(legacyQueries).toBe(2); + }); + + it("fails closed when the PR head changes before a terminal verdict is accepted", async () => { + vi.spyOn(globalThis, "fetch").mockImplementation(async (input) => { + const url = String(input); + return githubResponse( + url.includes("/pulls/42") + ? pullRequest({ head: { sha: "c".repeat(40) } }) + : listing([check()]), + ); + }); + + await expect( + waitForRequiredGate(identity, { timeoutMs: 100, pollIntervalMs: 10 }), + ).rejects.toThrow("no longer matches the exact head and base revision"); + }); +}); diff --git a/test/pr-risk-plan.test.ts b/test/pr-risk-plan.test.ts index 3bbe914d239..e94050c53b4 100644 --- a/test/pr-risk-plan.test.ts +++ b/test/pr-risk-plan.test.ts @@ -200,6 +200,7 @@ describe("deterministic PR risk plan", () => { "tools/advisors/io.mts", "tools/advisors/risk-plan.mts", "tools/e2e/pr-e2e-gate.mts", + "tools/e2e/pr-e2e-required.mts", "tools/e2e/risk-signal.ts", "tools/e2e/private-file.ts", "tools/e2e/workflow-plan.mts", @@ -232,7 +233,11 @@ describe("deterministic PR risk plan", () => { }); it("runs controller-only changes without credentialed E2E authorization", () => { - const result = plan(".github/workflows/pr-e2e-gate.yaml", "tools/e2e/pr-e2e-gate.mts"); + const result = plan( + ".github/workflows/pr-e2e-gate.yaml", + "tools/e2e/pr-e2e-gate.mts", + "tools/e2e/pr-e2e-required.mts", + ); expect(result.families.map((family) => family.id)).toContain("e2e-control-plane"); expect(requiresCredentialedE2eAuthorization(result)).toBe(false); diff --git a/test/vitest-watch-triggers.test.ts b/test/vitest-watch-triggers.test.ts index d00c8d62b31..cc2c0c85573 100644 --- a/test/vitest-watch-triggers.test.ts +++ b/test/vitest-watch-triggers.test.ts @@ -55,6 +55,7 @@ const OPAQUE_INPUTS = [ "test/e2e/manifests/openclaw-nvidia.yaml", "test/e2e/docs/parity-inventory.generated.json", ".github/workflows/e2e.yaml", + ".github/workflows/pr-e2e-gate.yaml", ".github/workflows/platform-vitest-main.yaml", "ci/platform-vitest-macos-requirements.lock", ] as const; @@ -101,6 +102,10 @@ describe("Vitest opaque-input watch triggers", () => { "test/e2e/support/e2e-migration-policy.test.ts", ]); expect(triggeredBy(".github/workflows/e2e.yaml")).toEqual(E2E_WORKFLOW_CONTRACTS); + expect(triggeredBy(".github/workflows/pr-e2e-gate.yaml")).toEqual([ + "test/pr-e2e-gate-workflow.test.ts", + "test/pr-e2e-required.test.ts", + ]); expect(triggeredBy(".github/workflows/platform-vitest-main.yaml")).toEqual([ "test/platform-vitest-main-workflow.test.ts", ]); diff --git a/tools/advisors/risk-plan.mts b/tools/advisors/risk-plan.mts index 634fef2aa65..a8f36b24525 100644 --- a/tools/advisors/risk-plan.mts +++ b/tools/advisors/risk-plan.mts @@ -74,6 +74,7 @@ const E2E_CONTROL_PLANE_FILES = new Set([ const CONTROLLER_ONLY_E2E_FILES = new Set([ ".github/workflows/pr-e2e-gate.yaml", "tools/e2e/pr-e2e-gate.mts", + "tools/e2e/pr-e2e-required.mts", ]); // These checked-in paths and directories are the source boundary for private-network, // policy, and shields enforcement but are not all covered by the token heuristics above. diff --git a/tools/e2e/pr-e2e-gate.mts b/tools/e2e/pr-e2e-gate.mts index add88c46c55..687c5b64dc0 100755 --- a/tools/e2e/pr-e2e-gate.mts +++ b/tools/e2e/pr-e2e-gate.mts @@ -31,7 +31,7 @@ const E2E_WORKFLOW = "e2e.yaml"; const E2E_WORKFLOW_PATH = `.github/workflows/${E2E_WORKFLOW}`; const PR_GATE_WORKFLOW_PATH = ".github/workflows/pr-e2e-gate.yaml"; const PR_GATE_APPROVAL_ENVIRONMENT = "approve-credentialed-e2e-skip-for-fork-pr"; -const CHECK_NAME = "E2E / PR Gate"; +const CHECK_NAME = "E2E / PR Gate Coordination"; const WORKFLOW_NAME = "E2E / PR Gate Controller"; const CHECK_EXTERNAL_ID_PREFIX = "nemoclaw-pr-e2e:v2"; const LEGACY_CHECK_EXTERNAL_ID_PREFIX = "nemoclaw-pr-e2e:v1"; diff --git a/tools/e2e/pr-e2e-required.mts b/tools/e2e/pr-e2e-required.mts new file mode 100644 index 00000000000..af46458b513 --- /dev/null +++ b/tools/e2e/pr-e2e-required.mts @@ -0,0 +1,312 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import fs from "node:fs"; +import { pathToFileURL } from "node:url"; + +import { githubApi } from "../advisors/github.mts"; +import { parseArgs } from "../advisors/io.mts"; + +const COORDINATION_CHECK_NAME = "E2E / PR Gate Coordination"; +const LEGACY_COORDINATION_CHECK_NAME = "E2E / PR Gate"; +const EXTERNAL_ID_PREFIX = "nemoclaw-pr-e2e:v2"; +const GITHUB_ACTIONS_APP_ID = 15368; +const USER_AGENT = "nemoclaw-pr-e2e-required"; +const SHA_PATTERN = /^[a-f0-9]{40}$/u; +const REPOSITORY_PATTERN = /^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/u; +const AUTHORIZATION_TITLES = new Set([ + "Maintainer approval required to skip credentialed E2E", + "Maintainer authorization required to run E2E", +]); + +type CheckConclusion = "success" | "failure" | "cancelled"; + +export type CoordinationCheckRun = { + id: number; + name: string; + head_sha: string; + external_id: string | null; + status: string; + conclusion: string | null; + details_url?: string | null; + output?: { title?: string | null }; + app?: { id?: number } | null; +}; + +type CheckRunsResponse = { + total_count: number; + check_runs: CoordinationCheckRun[]; +}; + +type PullRequest = { + number: number; + state: string; + head: { sha: string }; + base: { sha: string }; +}; + +export type RequiredGateIdentity = { + repository: string; + token: string; + prNumber: number; + headSha: string; + baseSha: string; +}; + +export type RequiredGateResult = { + conclusion: CheckConclusion; + title: string; + detailsUrl?: string; +}; + +function isObjectRecord(value: unknown): value is Record { + return !!value && typeof value === "object" && !Array.isArray(value); +} + +function requiredArgument(value: string | undefined, name: string): string { + if (!value) throw new Error(`--${name} is required`); + return value; +} + +function parsePositiveInteger(value: string | undefined, name: string): number { + const input = requiredArgument(value, name); + if (!/^[1-9][0-9]*$/u.test(input)) throw new Error(`--${name} must be a positive integer`); + const parsed = Number(input); + if (!Number.isSafeInteger(parsed)) throw new Error(`--${name} exceeds the safe integer range`); + return parsed; +} + +function assertIdentity(identity: RequiredGateIdentity): void { + if (!REPOSITORY_PATTERN.test(identity.repository)) { + throw new Error("GITHUB_REPOSITORY must be an owner/repository name"); + } + if (!identity.token) throw new Error("GITHUB_TOKEN is required"); + if (!Number.isSafeInteger(identity.prNumber) || identity.prNumber < 1) { + throw new Error("PR number is invalid"); + } + if (!SHA_PATTERN.test(identity.headSha)) throw new Error("PR head SHA is invalid"); + if (!SHA_PATTERN.test(identity.baseSha)) throw new Error("PR base SHA is invalid"); +} + +export function coordinationExternalId(prNumber: number, headSha: string, baseSha: string): string { + return `${EXTERNAL_ID_PREFIX}:${prNumber}:${headSha}:${baseSha}`; +} + +function validateCheckRunsResponse(value: unknown): CheckRunsResponse { + if ( + !isObjectRecord(value) || + !Number.isSafeInteger(value.total_count) || + (value.total_count as number) < 0 || + !Array.isArray(value.check_runs) || + value.check_runs.length !== value.total_count + ) { + throw new Error("GitHub returned an invalid or incomplete coordination check listing"); + } + for (const check of value.check_runs) { + if ( + !isObjectRecord(check) || + !Number.isSafeInteger(check.id) || + (check.id as number) < 1 || + typeof check.name !== "string" || + typeof check.head_sha !== "string" || + (check.external_id !== null && typeof check.external_id !== "string") + ) { + throw new Error("GitHub returned an invalid coordination check"); + } + } + return value as CheckRunsResponse; +} + +function validatePullRequest(value: unknown, identity: RequiredGateIdentity): PullRequest { + if ( + !isObjectRecord(value) || + value.number !== identity.prNumber || + value.state !== "open" || + !isObjectRecord(value.head) || + value.head.sha !== identity.headSha || + !isObjectRecord(value.base) || + value.base.sha !== identity.baseSha + ) { + throw new Error("PR no longer matches the exact head and base revision observed by this job"); + } + return value as PullRequest; +} + +async function requireExactPullRequest(identity: RequiredGateIdentity): Promise { + validatePullRequest( + await githubApi( + `repos/${identity.repository}/pulls/${identity.prNumber}`, + identity.token, + { userAgent: USER_AGENT }, + ), + identity, + ); +} + +async function matchingChecks( + identity: RequiredGateIdentity, + name: string, +): Promise { + const response = validateCheckRunsResponse( + await githubApi( + `repos/${identity.repository}/commits/${identity.headSha}/check-runs?check_name=${encodeURIComponent(name)}&filter=all&per_page=100`, + identity.token, + { userAgent: USER_AGENT }, + ), + ); + const externalId = coordinationExternalId(identity.prNumber, identity.headSha, identity.baseSha); + const claimed = response.check_runs.filter( + (check) => + check.name === name && + check.head_sha === identity.headSha && + check.external_id === externalId, + ); + if (claimed.some((check) => check.app?.id !== GITHUB_ACTIONS_APP_ID)) { + throw new Error("The exact-diff coordination identity was claimed by an unexpected GitHub App"); + } + return claimed.filter((check) => check.app?.id === GITHUB_ACTIONS_APP_ID); +} + +export async function findCoordinationCheck( + identity: RequiredGateIdentity, +): Promise { + assertIdentity(identity); + const current = await matchingChecks(identity, COORDINATION_CHECK_NAME); + if (current.length > 1) throw new Error("Multiple exact-diff coordination checks exist"); + if (current[0]) return current[0]; + + // Migration bridge for PRs whose base-branch controller still publishes the + // old name. Remove after this workflow is on main and open PRs resynchronize. + const legacy = await matchingChecks(identity, LEGACY_COORDINATION_CHECK_NAME); + if (legacy.length > 1) throw new Error("Multiple legacy exact-diff coordination checks exist"); + return legacy[0]; +} + +export function classifyCoordinationCheck( + check: CoordinationCheckRun | undefined, +): { state: "waiting"; description: string } | { state: "complete"; result: RequiredGateResult } { + if (!check) return { state: "waiting", description: "waiting for trusted coordination" }; + const title = check.output?.title?.trim() || "Trusted E2E coordination result"; + if (check.status !== "completed") { + return { state: "waiting", description: title }; + } + if (check.conclusion === "failure" && AUTHORIZATION_TITLES.has(title)) { + return { state: "waiting", description: title }; + } + if ( + !(["success", "failure", "cancelled"] as const).includes(check.conclusion as CheckConclusion) + ) { + throw new Error(`Coordination check completed with unsupported conclusion ${check.conclusion}`); + } + const detailsUrl = + typeof check.details_url === "string" && + /^https:\/\/github\.com\/[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+\/actions\/runs\/[1-9][0-9]*(?:\/attempts\/[1-9][0-9]*)?$/u.test( + check.details_url, + ) + ? check.details_url + : undefined; + return { + state: "complete", + result: { + conclusion: check.conclusion as CheckConclusion, + title, + ...(detailsUrl ? { detailsUrl } : {}), + }, + }; +} + +export async function waitForRequiredGate( + identity: RequiredGateIdentity, + options: { + timeoutMs: number; + pollIntervalMs?: number; + now?: () => number; + sleep?: (milliseconds: number) => Promise; + }, +): Promise { + assertIdentity(identity); + if (!Number.isSafeInteger(options.timeoutMs) || options.timeoutMs < 1) { + throw new Error("gate timeout is invalid"); + } + const pollIntervalMs = options.pollIntervalMs ?? 30_000; + if (!Number.isSafeInteger(pollIntervalMs) || pollIntervalMs < 1) { + throw new Error("gate poll interval is invalid"); + } + const now = options.now ?? Date.now; + const sleep = + options.sleep ?? + ((milliseconds: number) => new Promise((resolve) => setTimeout(resolve, milliseconds))); + const deadline = now() + options.timeoutMs; + let lastDescription = ""; + + await requireExactPullRequest(identity); + while (now() < deadline) { + const classified = classifyCoordinationCheck(await findCoordinationCheck(identity)); + if (classified.state === "complete") { + await requireExactPullRequest(identity); + return classified.result; + } + if (classified.description !== lastDescription) { + console.log(`E2E / PR Gate: ${classified.description}`); + lastDescription = classified.description; + } + await sleep(Math.min(pollIntervalMs, Math.max(1, deadline - now()))); + } + throw new Error("Timed out waiting for the trusted exact-diff E2E verdict"); +} + +function escapedHtml(value: string): string { + return value.replace(/&/gu, "&").replace(//gu, ">"); +} + +function appendJobSummary(result: RequiredGateResult): void { + const summaryPath = process.env.GITHUB_STEP_SUMMARY; + if (!summaryPath) return; + const descriptor = fs.openSync( + summaryPath, + fs.constants.O_WRONLY | fs.constants.O_APPEND | (fs.constants.O_NOFOLLOW ?? 0), + ); + try { + if (!fs.fstatSync(descriptor).isFile()) { + throw new Error("GITHUB_STEP_SUMMARY must be a regular file"); + } + const lines = [ + "## E2E / PR Gate", + "", + `${escapedHtml(result.conclusion)}: ${escapedHtml(result.title)}`, + ...(result.detailsUrl ? ["", `[Open trusted run](${result.detailsUrl})`] : []), + "", + ]; + fs.writeFileSync(descriptor, `${lines.join("\n")}\n`, "utf8"); + } finally { + fs.closeSync(descriptor); + } +} + +async function main(): Promise { + const args = parseArgs(process.argv.slice(2)); + const identity: RequiredGateIdentity = { + repository: process.env.GITHUB_REPOSITORY ?? "", + token: process.env.GITHUB_TOKEN ?? "", + prNumber: parsePositiveInteger(args.pr, "pr"), + headSha: requiredArgument(args.head, "head"), + baseSha: requiredArgument(args.base, "base"), + }; + const timeoutSeconds = parsePositiveInteger(args.timeoutSeconds, "timeout-seconds"); + if (timeoutSeconds > 10_200) throw new Error("--timeout-seconds must not exceed 10200"); + const result = await waitForRequiredGate(identity, { timeoutMs: timeoutSeconds * 1000 }); + appendJobSummary(result); + console.log(`E2E / PR Gate completed: conclusion=${result.conclusion} title=${result.title}`); + if (result.conclusion !== "success") { + throw new Error(`Trusted exact-diff E2E verdict was ${result.conclusion}: ${result.title}`); + } +} + +if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { + main().catch((error: unknown) => { + const message = error instanceof Error ? error.message : String(error); + const escaped = message.replace(/%/gu, "%25").replace(/\r/gu, "%0D").replace(/\n/gu, "%0A"); + console.error(`::error title=E2E / PR Gate failed::${escaped}`); + process.exit(1); + }); +} From b0e14bb85bb1f3a0e235001fc1fbebc5182ea1ca Mon Sep 17 00:00:00 2001 From: Carlos Villela Date: Wed, 15 Jul 2026 01:29:44 -0700 Subject: [PATCH 2/4] test(ci): satisfy conditional growth guard Signed-off-by: Carlos Villela --- test/pr-e2e-required.test.ts | 45 +++++++++++++++++++++++------------- 1 file changed, 29 insertions(+), 16 deletions(-) diff --git a/test/pr-e2e-required.test.ts b/test/pr-e2e-required.test.ts index a677a0e7892..2bcbcda5197 100644 --- a/test/pr-e2e-required.test.ts +++ b/test/pr-e2e-required.test.ts @@ -13,6 +13,7 @@ import { type RequiredGateIdentity, waitForRequiredGate, } from "../tools/e2e/pr-e2e-required.mts"; +import { createGitHubFetchRouter, githubFetchRoute } from "./support/github-fetch-router.ts"; const HEAD_SHA = "a".repeat(40); const BASE_SHA = "b".repeat(40); @@ -149,22 +150,34 @@ describe("native PR E2E required job", () => { it("waits through authorization and revalidates the exact PR before passing", async () => { let legacyQueries = 0; let clock = 0; - vi.spyOn(globalThis, "fetch").mockImplementation(async (input) => { - const url = String(input); - if (url.includes("/pulls/42")) return githubResponse(pullRequest()); - if (url.includes("Coordination")) return githubResponse(listing([])); - legacyQueries += 1; - return githubResponse( - listing([ - legacyQueries === 1 - ? check("E2E / PR Gate", { - conclusion: "failure", - output: { title: "Maintainer authorization required to run E2E" }, - }) - : check("E2E / PR Gate"), - ]), - ); - }); + vi.spyOn(globalThis, "fetch").mockImplementation( + createGitHubFetchRouter([ + githubFetchRoute( + ({ url }) => url.includes("/pulls/42"), + () => githubResponse(pullRequest()), + ), + githubFetchRoute( + ({ url }) => url.includes("Coordination"), + () => githubResponse(listing([])), + ), + githubFetchRoute( + ({ url }) => url.includes("/check-runs") && !url.includes("Coordination"), + () => { + legacyQueries += 1; + return githubResponse( + listing([ + legacyQueries === 1 + ? check("E2E / PR Gate", { + conclusion: "failure", + output: { title: "Maintainer authorization required to run E2E" }, + }) + : check("E2E / PR Gate"), + ]), + ); + }, + ), + ]), + ); await expect( waitForRequiredGate(identity, { From 125144a0ea3e6dd26ccc85ff89cc40aa102c8785 Mon Sep 17 00:00:00 2001 From: Carlos Villela Date: Wed, 15 Jul 2026 01:37:54 -0700 Subject: [PATCH 3/4] fix(ci): keep gate summary free of network data Signed-off-by: Carlos Villela --- test/e2e/README.md | 3 ++- test/e2e/docs/README.md | 3 ++- tools/e2e/pr-e2e-required.mts | 22 ++++++++-------------- 3 files changed, 12 insertions(+), 16 deletions(-) diff --git a/test/e2e/README.md b/test/e2e/README.md index d965ad273a1..5d6280868ec 100644 --- a/test/e2e/README.md +++ b/test/e2e/README.md @@ -97,7 +97,8 @@ Actions job named `E2E / PR Gate`. It checks out the controller at `github.workflow_sha`, validates that the PR still has the observed head and base, waits for the matching trusted coordination identity, and exits with its terminal verdict. It also writes that verdict and the trusted run link to the -job summary. During rollout, the observer accepts the former custom-check name +job log and keeps the job summary free of network-derived content. During +rollout, the observer accepts the former custom-check name `E2E / PR Gate` for the same exact-diff external identity so in-flight PRs do not lose their gate. diff --git a/test/e2e/docs/README.md b/test/e2e/docs/README.md index 4341e263fbb..f24249963b2 100644 --- a/test/e2e/docs/README.md +++ b/test/e2e/docs/README.md @@ -122,7 +122,8 @@ test/e2e/ `E2E / PR Gate`. The read-only observer runs from `github.workflow_sha`, validates the live PR head and base, waits for the matching trusted coordination identity, and mirrors the terminal verdict into the required - job and its summary. Authorization states remain pending while the maintainer + job. Its summary is static, while the job log includes the validated trusted + controller-run link. Authorization states remain pending while the maintainer decision is recorded. During rollout, the observer also accepts the former `E2E / PR Gate` custom-check name for the same exact-diff identity. The controller builds the risk plan from GitHub's complete file list. Internal diff --git a/tools/e2e/pr-e2e-required.mts b/tools/e2e/pr-e2e-required.mts index af46458b513..ba1236c111d 100644 --- a/tools/e2e/pr-e2e-required.mts +++ b/tools/e2e/pr-e2e-required.mts @@ -255,11 +255,7 @@ export async function waitForRequiredGate( throw new Error("Timed out waiting for the trusted exact-diff E2E verdict"); } -function escapedHtml(value: string): string { - return value.replace(/&/gu, "&").replace(//gu, ">"); -} - -function appendJobSummary(result: RequiredGateResult): void { +function appendJobSummary(): void { const summaryPath = process.env.GITHUB_STEP_SUMMARY; if (!summaryPath) return; const descriptor = fs.openSync( @@ -270,14 +266,11 @@ function appendJobSummary(result: RequiredGateResult): void { if (!fs.fstatSync(descriptor).isFile()) { throw new Error("GITHUB_STEP_SUMMARY must be a regular file"); } - const lines = [ - "## E2E / PR Gate", - "", - `${escapedHtml(result.conclusion)}: ${escapedHtml(result.title)}`, - ...(result.detailsUrl ? ["", `[Open trusted run](${result.detailsUrl})`] : []), - "", - ]; - fs.writeFileSync(descriptor, `${lines.join("\n")}\n`, "utf8"); + fs.writeFileSync( + descriptor, + "## E2E / PR Gate\n\nThis native job mirrors the trusted exact-diff E2E coordination result. See the job log for the validated controller run.\n", + "utf8", + ); } finally { fs.closeSync(descriptor); } @@ -295,7 +288,8 @@ async function main(): Promise { const timeoutSeconds = parsePositiveInteger(args.timeoutSeconds, "timeout-seconds"); if (timeoutSeconds > 10_200) throw new Error("--timeout-seconds must not exceed 10200"); const result = await waitForRequiredGate(identity, { timeoutMs: timeoutSeconds * 1000 }); - appendJobSummary(result); + appendJobSummary(); + if (result.detailsUrl) console.log(`Trusted E2E coordination run: ${result.detailsUrl}`); console.log(`E2E / PR Gate completed: conclusion=${result.conclusion} title=${result.title}`); if (result.conclusion !== "success") { throw new Error(`Trusted exact-diff E2E verdict was ${result.conclusion}: ${result.title}`); From a83a40186a31528c976c1cc6c860aed4b381eea2 Mon Sep 17 00:00:00 2001 From: Carlos Villela Date: Wed, 15 Jul 2026 01:44:34 -0700 Subject: [PATCH 4/4] refactor(ci): name trusted E2E file boundary Signed-off-by: Carlos Villela --- tools/advisors/risk-plan.mts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/tools/advisors/risk-plan.mts b/tools/advisors/risk-plan.mts index a8f36b24525..e743a2ecfa6 100644 --- a/tools/advisors/risk-plan.mts +++ b/tools/advisors/risk-plan.mts @@ -71,7 +71,7 @@ const E2E_CONTROL_PLANE_FILES = new Set([ "tools/advisors/risk-plan.mts", "vitest.config.ts", ]); -const CONTROLLER_ONLY_E2E_FILES = new Set([ +const TRUSTED_CONTROL_PLANE_ONLY_FILES = new Set([ ".github/workflows/pr-e2e-gate.yaml", "tools/e2e/pr-e2e-gate.mts", "tools/e2e/pr-e2e-required.mts", @@ -408,5 +408,7 @@ export function riskPlanRequiredJobIds(plan: RiskPlan): string[] { export function requiresCredentialedE2eAuthorization(plan: RiskPlan): boolean { const controlPlane = plan.families.find((family) => family.id === "e2e-control-plane"); - return controlPlane?.matchedFiles.some((file) => !CONTROLLER_ONLY_E2E_FILES.has(file)) ?? false; + return ( + controlPlane?.matchedFiles.some((file) => !TRUSTED_CONTROL_PLANE_ONLY_FILES.has(file)) ?? false + ); }