# Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. # GP2 — Evidence ingest (#1402). # # Verifies published, signed evidence bundles and writes the source-keyed # evidence tree the GP4 corroborate generator consumes, then uploads that # tree to the corroboration GCS bucket. Two triggers converge on the same # verify -> synthesize -> upload pipeline: # # 1. push to main touching recipes/evidence/** — community/partner # pointers added/refreshed in-tree. # 2. workflow_call / workflow_dispatch with a bundle_ref — first-party # UAT runs ingest directly, by bundle ref, with no repo commit. # # Fork safety, per hippo:reviewer-cicd norms: verification runs in a job # that holds NO bucket-write credentials; the credentialed publish job # runs only on the canonical repo (never a fork) in a separate job. It # reuses the eidosx WIF provider + service account from uat-gcp.yaml and # writes to the existing aicr-testgrid-staging bucket under a results/ # prefix. GP3 will swap in a dedicated objectCreator-only SA. name: "Evidence: Ingest" on: push: branches: [main] paths: - 'recipes/evidence/**' workflow_dispatch: inputs: bundle_ref: description: 'OCI ref of a signed evidence bundle to ingest (registry/repo@sha256:...)' required: true type: string recipe: description: 'Recipe name (optional; informational)' required: false type: string workflow_call: inputs: bundle_ref: description: 'OCI ref of a signed evidence bundle to ingest (registry/repo@sha256:...)' required: true type: string recipe: description: 'Recipe name (optional; informational)' required: false type: string permissions: contents: read concurrency: group: ${{ github.workflow }}-${{ github.ref }} cancel-in-progress: false env: # Registry/repo prefixes a bundle ref must fall under before it is # pulled. Overridable via the EVIDENCE_TRUSTED_REGISTRIES repo var. TRUSTED_REGISTRIES: ${{ vars.EVIDENCE_TRUSTED_REGISTRIES || 'ghcr.io/nvidia' }} # Allowed OIDC issuer for first-party (UAT) bundles. FIRST_PARTY_ISSUER: 'https://token.actions.githubusercontent.com' # Identity regexp pinning first-party signatures to the NVIDIA/aicr # evidence-producing UAT lanes. Fully anchored so the verifier's signer # match is exact (matches what each lane pins in its own verify step): the # cloud lanes (uat-aws/gcp/azure) plus the real-silicon nvkind lane # (uat-kind, DC5 #1278). Keep in lockstep with the firstParty # identityPattern entries in recipes/evidence/allowlist.yaml. FIRST_PARTY_IDENTITY: '^https://github\.com/NVIDIA/aicr/\.github/workflows/uat-(aws|gcp|azure|kind)\.yaml@refs/heads/.+$' jobs: # --------------------------------------------------------------------- # Job 1: verify + synthesize. Holds NO GCS credentials. Its only output # is the local source-keyed tree, uploaded as an artifact. # --------------------------------------------------------------------- verify: name: Verify and synthesize # Never runs on a fork (push to main on a fork would still lack the # bundles; the publish job is the real credential gate). if: github.repository == 'nvidia/aicr' runs-on: ubuntu-24.04 timeout-minutes: 20 permissions: contents: read packages: read outputs: # 'true' only when the ingest actually produced a tree. The push # trigger legitimately ingests nothing (allowlist-only change, a # deleted pointer, no changed pointers); publish keys off this so it # does not run and fail on a missing artifact. produced: ${{ steps.detect.outputs.produced }} steps: - name: Checkout uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false # Full history: the push diff compares github.event.before..HEAD, # and a single push can span more than one commit (before is then # older than HEAD~1). A shallow fetch-depth: 2 would miss the base # and the diff would silently match nothing. fetch-depth: 0 - name: Load versions id: versions uses: ./.github/actions/load-versions - name: Set up Go uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0 with: go-version: ${{ steps.versions.outputs.go }} cache: false - name: Build evidence-project run: go build -o ./bin/evidence-project ./tools/evidence-project - name: Authenticate to GHCR uses: ./.github/actions/ghcr-login - name: Ingest env: INGEST: ${{ github.workspace }}/bin/evidence-project BUNDLE_REF: ${{ inputs.bundle_ref }} # First-party runId. On a workflow_call from UAT, github.run_id # is the SAME run that pushed the bundle (the run_id embedded in # the OCI artifact name), so the tree entry is unique and traces # back to both. Used only in the single-bundle (BUNDLE_REF) path; # the push path derives a per-pointer runId from attestedAt. FIRST_PARTY_RUN_ID: run-${{ github.run_id }} BEFORE_SHA: ${{ github.event.before }} HEAD_SHA: ${{ github.sha }} OUT_DIR: ${{ github.workspace }}/out run: .github/scripts/evidence-ingest.sh - name: Detect produced tree id: detect run: | set -euo pipefail if [ -d "${{ github.workspace }}/out/results" ] \ && [ -n "$(find "${{ github.workspace }}/out/results" -type f -print -quit)" ]; then echo "produced=true" >> "$GITHUB_OUTPUT" else echo "produced=false" >> "$GITHUB_OUTPUT" echo "no evidence tree produced — publish will be skipped" fi - name: Upload evidence tree if: ${{ steps.detect.outputs.produced == 'true' }} uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 with: name: evidence-tree path: ${{ github.workspace }}/out if-no-files-found: ignore retention-days: 7 # --------------------------------------------------------------------- # Job 2: publish to GCS. Separate job that holds the GCP credentials — # the verify job above holds none. Reuses the WIF provider + service # account from uat-gcp.yaml (eidosx project) and writes to the existing # aicr-testgrid-staging bucket under its own results/ prefix, which does # not collide with the bucket's other top-level paths (config, grid/, # groups/, queue/, summary/, tabs/). # # GP3 hardening: swap GCP_WIF_SERVICE_ACCOUNT for a dedicated # objectCreator-only identity scoped to gs://aicr-testgrid-staging/results # rather than the shared github-actions@eidosx SA. # --------------------------------------------------------------------- publish: name: Publish to GCS needs: verify # Only when verify actually produced a tree — an empty push-mode ingest # uploads no artifact, and download-artifact would otherwise fail hard. if: github.repository == 'nvidia/aicr' && needs.verify.outputs.produced == 'true' runs-on: ubuntu-24.04 timeout-minutes: 15 permissions: contents: read id-token: write # WIF only; no bucket creds in the verify job env: GCP_WIF_PROVIDER: "projects/116689922666/locations/global/workloadIdentityPools/github-actions-pool/providers/github-actions-provider" GCP_WIF_SERVICE_ACCOUNT: "github-actions@eidosx.iam.gserviceaccount.com" BUCKET: "aicr-testgrid-staging" steps: - name: Download evidence tree uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2 with: name: evidence-tree path: out - name: Authenticate to GCP uses: google-github-actions/auth@7c6bc770dae815cd3e89ee6cdf493a5fab2cc093 # v3.0.0 with: workload_identity_provider: ${{ env.GCP_WIF_PROVIDER }} service_account: ${{ env.GCP_WIF_SERVICE_ACCOUNT }} - name: Set up gcloud uses: google-github-actions/setup-gcloud@aa5489c8933f4cc7a4f7d45035b3b1440c9c10db # v3.0.1 - name: Sync source-keyed tree to GCS run: | set -euo pipefail if [ ! -d out/results ]; then echo "no results to publish" exit 0 fi # Each leaf run directory (the one holding meta.json) is replaced # in place with a delete-aware rsync scoped to that run's prefix, # so a re-ingest of the same run drops stale ctrf/*.json from a # prior, larger run instead of leaving them behind. The delete is # confined to the single run prefix, so other sources, other runs, # and the bucket's other top-level prefixes are never touched. find out/results -type f -name meta.json -print0 \ | while IFS= read -r -d '' meta; do run_dir="$(dirname "${meta}")" rel="${run_dir#out/results/}" echo "syncing run ${rel}" gcloud storage rsync -r \ --delete-unmatched-destination-objects \ "${run_dir}" "gs://${BUCKET}/results/${rel}" done # --------------------------------------------------------------------- # Job 3: trigger downstream evidence consumers. Every successful ingest lands # verified evidence in GCS, so the static dashboard must re-render and the # live TestGrid publisher must receive the same digest-pinned bundle ref. # # The first-party UAT path is a nested workflow_call (uat-run -> cloud UAT -> # evidence-ingest), where workflow_run chaining is suppressed for nightly runs # initiated by GITHUB_TOKEN. Explicit workflow_dispatch starts fresh top-level # runs regardless of nesting depth or the original UAT actor. # # Held in a separate job so the GCS-credentialed publish job never also # carries actions:write. `needs: publish` ensures downstream consumers are # triggered only after verified evidence is persisted. # # Dispatch is best-effort: evidence is already durable, and a transient # dispatch failure must not turn a successful multi-hour UAT cell red. Each # dispatch retries and emits a visible warning on exhaustion. The dashboard # self-heals on the next ingest; TestGrid can be backfilled by bundle ref. # --------------------------------------------------------------------- trigger-publishes: name: Trigger evidence publishes needs: publish if: github.repository == 'nvidia/aicr' runs-on: ubuntu-24.04 timeout-minutes: 5 permissions: contents: read actions: write # dispatch dashboard and TestGrid workflows steps: - name: Dispatch dashboard publish env: GH_TOKEN: ${{ github.token }} REPO: ${{ github.repository }} run: | set -euo pipefail # Always target main: the dashboard deploys only from the default # branch, and the evidence tree it renders is the canonical GCS # bucket regardless of which ref this ingest ran on. # # Bounded retry, then warn-and-succeed: the `if` guard keeps a failed # attempt from tripping `set -e`, and the trailing warning exits 0 so a # best-effort refresh cannot fail the calling UAT run. for attempt in 1 2 3; do if gh workflow run evidence-dashboard-publish.yaml \ --repo "$REPO" --ref main; then echo "dispatched Evidence: Dashboard Publish on main" exit 0 fi echo "dispatch attempt ${attempt} failed; retrying in 5s..." sleep 5 done echo "::warning::could not dispatch Evidence: Dashboard Publish after 3 attempts; the dashboard will refresh on the next ingest or push to main" - name: Dispatch TestGrid publish if: >- inputs.bundle_ref != '' && (github.ref_name == 'main' || startsWith(github.ref_name, 'release/')) env: BUNDLE_REF: ${{ inputs.bundle_ref }} GH_TOKEN: ${{ github.token }} REPO: ${{ github.repository }} run: | set -euo pipefail # Called from UAT, the github context is the caller's, so this run # (and attempt) is the UAT run that produced the bundle; the # dispatched publish is a new run and cannot see it. A direct # dispatch of this workflow re-ingests an existing bundle, whose # producing run is unknown here, so it publishes no run link. RUN_URL="" if [[ "${GITHUB_WORKFLOW_REF}" != "${REPO}/.github/workflows/evidence-ingest.yaml@"* ]]; then RUN_URL="${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}/actions/runs/${GITHUB_RUN_ID}/attempts/${GITHUB_RUN_ATTEMPT}" fi for attempt in 1 2 3; do if gh workflow run testgrid-publish.yml \ --repo "${REPO}" --ref main \ -f bundle_ref="${BUNDLE_REF}" \ -f environment=prod \ -f run_url="${RUN_URL}"; then echo "dispatched TestGrid Publish on main" exit 0 fi echo "dispatch attempt ${attempt} failed; retrying in 5s..." sleep 5 done echo "::warning::could not dispatch TestGrid Publish after 3 attempts; backfill bundle ${BUNDLE_REF} manually with run_url=${RUN_URL:-}"