fix: enforce closed-world bundle verification (#1758) #2
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| # 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. | |
| # Fork-based evidence signing — the Fulcio-bound leg of two-phase publish. | |
| # | |
| # A contributor publishes an UNSIGNED evidence bundle locally (where the | |
| # cluster lives, often behind a VPN that blocks Sigstore) and commits the | |
| # pointer FLAT — recipes/evidence/<recipe>.yaml — because the nested | |
| # <source> path segment derives from the signer a `--no-sign` pointer does | |
| # not yet have: | |
| # | |
| # aicr validate -r recipes/overlays/<slug>.yaml -s snapshot.yaml \ | |
| # --emit-attestation ./out --push ghcr.io/<owner>/aicr-evidence --no-sign | |
| # cp ./out/pointer.yaml recipes/evidence/<recipe>.yaml | |
| # | |
| # They then run THIS workflow from their fork's Actions tab. It scans every | |
| # flat pointer under recipes/evidence/ except allowlist.yaml and runs | |
| # `aicr evidence sign --relocate` on each: an unsigned pointer is signed with | |
| # the runner's ambient OIDC identity (where Sigstore IS reachable) and then | |
| # relocated; an already-signed flat pointer left by a partial prior run is | |
| # relocated without re-signing (idempotent). The destination is the canonical | |
| # per-source path recipes/evidence/<recipe>/<source>/<digest>.yaml (the layout | |
| # the per-source contract gate requires), and the move is committed back to the | |
| # branch. Clean no-op when there are no flat pointers. This is the | |
| # commit-flat -> CI-sign -> CI-relocate-to-nested flow (#1530). | |
| # | |
| # Why workflow_dispatch (Option A): no recipe-name or bundle-ref inputs to | |
| # thread through — the committed pointer is the work item, and it already | |
| # carries bundle.oci + bundle.digest. Manual dispatch keeps the commit-back | |
| # predictable (no surprise commits) and avoids the loop-guards an automatic | |
| # push trigger would require. | |
| # | |
| # Two triggers: | |
| # * workflow_dispatch — manual, works anywhere (incl. upstream). | |
| # * push to recipes/evidence/** — auto-sign (Option B), forks only. | |
| # | |
| # Loop / scope guards for the auto-trigger (it commits back to the branch): | |
| # * GitHub does not re-run workflows for pushes made with the default | |
| # GITHUB_TOKEN, so the commit-back below does not re-trigger this on its | |
| # own. The job `if:` also skips a push whose head commit is our own | |
| # signing commit — belt-and-suspenders for forks pushing via a PAT. | |
| # * The job `if:` restricts the push path to forks (github.repository != | |
| # 'NVIDIA/aicr') and skips the fork's default branch (matched dynamically | |
| # via github.event.repository.default_branch, so a non-`main` default is | |
| # still covered), so it never auto-commits to a default branch. | |
| # * The signer script no-ops cleanly when no unsigned pointer is present. | |
| # * On forks with Actions or id-token disabled, the workflow simply does | |
| # not run / the sign step fails closed with a clear message. | |
| # | |
| # Security note: this runs in the *contributor's fork* on a branch they | |
| # control, signing with the fork's own GitHub Actions OIDC identity. The | |
| # id-token:write / contents:write grants act on the fork, not upstream — | |
| # there is no pull_request_target / untrusted-ref exposure (it is manual | |
| # dispatch on the fork's own code). | |
| # | |
| # The fork's aicr-evidence registry package must be PUBLIC: the upstream | |
| # evidence gate fetches the bundle anonymously, and a private package returns | |
| # HTTP 403. The signing step fails with a clear message pointing at this when | |
| # it can't pull. The signature ATTACH (a referrer write) is authenticated | |
| # separately by the ghcr-login step + packages:write below — public visibility | |
| # alone does not grant write. | |
| name: "Recipe Evidence: Sign" | |
| on: | |
| workflow_dispatch: {} | |
| push: | |
| paths: | |
| - 'recipes/evidence/**' | |
| permissions: | |
| contents: write # commit the patched pointer(s) back to the branch | |
| id-token: write # ambient OIDC for keyless Fulcio/Rekor signing | |
| # packages:write attaches the Sigstore bundle as an OCI referrer of the | |
| # already-pushed artifact (a registry write — making the package public only | |
| # enables the anonymous pre-sign pull, not the signature attach). | |
| packages: write | |
| concurrency: | |
| # Serialize per branch; do NOT cancel in progress — a cancel mid-sign | |
| # could leave a bundle signed in the registry but its pointer un-patched. | |
| group: ${{ github.workflow }}-${{ github.ref }} | |
| cancel-in-progress: false | |
| jobs: | |
| sign: | |
| name: Sign unsigned evidence pointers | |
| runs-on: ubuntu-latest | |
| timeout-minutes: 20 | |
| # Manual dispatch runs anywhere. The push auto-trigger runs only in forks | |
| # (never the canonical repo), never on the fork's default branch (matched | |
| # dynamically so a renamed default is still covered), and skips our own | |
| # signing commit so the commit-back can't loop (GITHUB_TOKEN pushes already | |
| # don't re-trigger; this also covers forks pushing via a PAT). | |
| if: >- | |
| github.event_name == 'workflow_dispatch' || | |
| (github.repository != 'NVIDIA/aicr' && | |
| github.ref_name != github.event.repository.default_branch && | |
| !startsWith(github.event.head_commit.message, 'chore(evidence): sign pending evidence pointers')) | |
| steps: | |
| - name: Checkout | |
| uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 | |
| with: | |
| # Do not persist a token in .git/config: it would sit there across | |
| # the fork-controlled `go build` step. The commit-back does not need | |
| # it — it goes through the GitHub API (createCommitOnBranch), which | |
| # authenticates via GH_TOKEN in the commit step's env. | |
| persist-credentials: false | |
| - name: Load versions | |
| id: versions | |
| uses: ./.github/actions/load-versions | |
| - name: Set up Go | |
| uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6.5.0 | |
| with: | |
| go-version: ${{ steps.versions.outputs.go }} | |
| cache: false | |
| - name: Build aicr | |
| env: | |
| GOFLAGS: -mod=vendor | |
| run: | | |
| # Verify the vendored dependency tree against go.sum before building | |
| # the binary that will sign with the runner's OIDC identity. | |
| go mod verify | |
| go build -o ./bin/aicr ./cmd/aicr | |
| # `aicr evidence sign` attaches the Sigstore bundle as an OCI referrer — | |
| # a registry write — so authenticate first (ORAS reads Docker creds). The | |
| # public-package setting only enables the anonymous pre-sign pull. For a | |
| # non-GHCR registry, swap this for that registry's login. | |
| - name: Authenticate to GHCR | |
| uses: ./.github/actions/ghcr-login | |
| - name: Sign unsigned evidence pointers | |
| id: sign | |
| env: | |
| AICR: ${{ github.workspace }}/bin/aicr | |
| run: .github/scripts/evidence-sign-unsigned.sh | |
| - name: Commit signed pointers | |
| # always(), with no signed-count guard: the git-diff check inside the | |
| # script decides whether there is anything to commit. This is | |
| # load-bearing for partial failure — `aicr evidence sign` patches the | |
| # signer block into the flat pointer BEFORE the relocation step, so if | |
| # relocation then fails the bundle is already signed in the registry. | |
| # Committing the signed (but not-yet-relocated) pointer persists that | |
| # work, so the next dispatch takes the idempotent relocate-only path | |
| # instead of re-signing and attaching a duplicate referrer. The job | |
| # still fails (the sign step exited non-zero), surfacing the failure. | |
| # | |
| # The commit-back is created via GitHub's GraphQL createCommitOnBranch | |
| # mutation rather than `git push`, so GitHub applies its web-flow | |
| # signature and the commit shows Verified (#1551). The DCO sign-off is | |
| # preserved in the commit body; the loop guard holds because a commit | |
| # authored by GITHUB_TOKEN does not trigger workflow runs. | |
| if: ${{ always() }} | |
| env: | |
| GH_TOKEN: ${{ github.token }} | |
| run: .github/scripts/evidence-commit-signed.sh |