Publishing Recipe Evidence
Recipe evidence is a signed bundle that proves a recipe’s validators passed
on hardware matching its criteria. Producing it has two legs:
- Validate + push (network-light). Runs where the cluster lives — often a corporate VPN. Captures a snapshot, runs the validators, builds the bundle, and pushes it to an OCI registry.
- Sign (Fulcio-bound). Signs the pushed bundle with keyless OIDC (Sigstore Fulcio + Rekor) and records the signature in the committed pointer.
The two legs need different network access, and the signing leg is the one contributors most often can’t complete locally.
The Fulcio connectivity problem
Keyless signing reaches fulcio.sigstore.dev and rekor.sigstore.dev.
Corporate VPNs and some home networks block TLS to these hosts — the
connection is rejected at the IP level upstream, not by AICR. The symptom
is a hang or a TLS/connection-reset error during the sign step, even though
aicr validate and the registry push succeed.
This is not an AICR bug and AICR cannot work around it; the block is on the path to Sigstore’s public-good infrastructure. Contributors have reported needing a phone hotspot to sign from a laptop.
Recommended path: split the legs, sign in CI
The fix is to keep the network-light leg local and move only the Fulcio-bound leg to GitHub Actions, where Sigstore is reachable. The signer identity becomes your fork’s GitHub Actions OIDC identity rather than a personal one.
1. Validate and push an unsigned bundle (local, on VPN)
--no-sign skips all OIDC/Fulcio/Rekor work, so this runs even where
Sigstore egress is blocked. It pushes the content-addressed bundle and
writes a pointer with an empty signer block.
Commit it flat at recipes/evidence/<recipe>.yaml (where <recipe> is
the pointer’s recipe: field). This is the only committable location for
an unsigned pointer: the nested per-source path
recipes/evidence/<recipe>/<src>/<digest>.yaml includes a <src>
segment derived from the signer, which a --no-sign pointer does not yet
have. The signing leg below relocates the pointer to that nested path once it
is signed. See
aicr validate and
aicr evidence publish
(which also accepts --no-sign if you push as a separate step).
Make the registry package public. The signing step (and the repository’s evidence gate) pull the bundle back to sign and verify it. If your fork’s
aicr-evidencepackage is private, the pull fails with an HTTP 403 and the gate reportsregistry-forbidden. On GHCR, set theaicr-evidencepackage visibility to public under your account’s Packages settings. Any OCI-1.1 registry works; it just has to be readable.
Grant the fork’s Actions write access to the package. Signing in CI attaches the signature as an OCI referrer — a registry write, separate from the public-read above. Two GHCR prerequisites the signing leg needs:
- The package’s Actions access must include your fork repo with the Write role (GHCR package → Settings → Manage Actions access → add your fork). Without it the attach fails with HTTP 403 even though
packages: writeis granted in the workflow.- A package first pushed from a local
aicr validate --pushmay be linked to NVIDIA/aicr (the chart/repo it was built against) rather than your fork. Re-link it to your fork (GHCR package → Settings → Change repository) so your fork’s Actions token is authorized. The first push from the fork’s own CI links it correctly.
2. Commit the unsigned pointer and push your branch
aicr evidence verify reports an unsigned pointer as a non-failing
pending signature state, so an in-flight PR is not flagged as broken.
3. Sign in your fork via GitHub Actions
The Recipe Evidence: Sign workflow (.github/workflows/evidence-publish.yaml)
runs two ways in your fork:
- Automatically when you push a change under
recipes/evidence/**to any branch other than your default — so step 2’s push usually triggers it for you. - Manually from your fork’s Actions tab (
workflow_dispatch, no inputs) — use this to re-run after making the registry package public, or if the auto-run didn’t fire.
The auto-trigger is fork-only (it never runs on the canonical repo) and skips its own signing commit, so it can’t loop. The workflow:
- discovers every flat pointer in
recipes/evidence/*.yamlwith an emptysigner(i.e. unsigned), - signs the bundle each one already references using the runner’s ambient
OIDC token and relocates the now-signed pointer to its canonical
per-source path
recipes/evidence/<recipe>/<src>/<digest>.yaml(aicr evidence sign --relocate), - commits the move back to the branch.
It is a clean no-op when there are no flat pointers, and it fails with a
clear message if it cannot pull a bundle (the public-package requirement
above). Pull the commit it pushes (git pull) — your PR now carries a
signed, nested pointer (the flat pending file is gone). The bundle is
signed; the commit-back itself is a normal, unsigned GitHub Actions commit,
which the eventual squash-merge re-signs under the repo’s policy.
Run this leg before merge. The blocking per-source contract gate requires a signed, nested pointer; it rejects a flat pending pointer still sitting at the evidence root. That is deliberate — an unsigned pointer must not merge to
main, where the fork-only sign workflow would never run to complete it. So a PR whose pointer is still flat stays red until this leg signs and relocates it (and yougit pullthe commit-back). Local tooling can validate the transient flat state withevidence-pointercheck --allow-pending, but the merge gate does not set it.
The workflow declares
id-token: writein its ownpermissions:block — it is not a default. Your fork must have GitHub Actions enabled and not restrict workflow OIDC token issuance (the default fork setting allows it).Avoid pushing other commits to the branch while the workflow runs: it commits the patched pointers back, and a concurrent push would cause a non-fast-forward — re-dispatch after
git pullif that happens.
4. Add your signer to the allowlist
A signed, nested pointer is not sufficient on its own. The contract gate
also requires the pointer’s claimed signer to be listed in
recipes/evidence/allowlist.yaml as a
community or partner entry; an unlisted signer is rejected (it would only
ever count as “reported”, never corroborating). Your fork’s GitHub Actions OIDC
identity is a new signer that the existing entries do not cover, so you must
add it in the same PR.
The entry is keyed by the one-way source slug — the exact <src> directory
the signing leg just created under recipes/evidence/<recipe>/<src>/. Add it
under the community class (no cleartext identity; an optional label may
carry a non-PII handle):
See the header of recipes/evidence/allowlist.yaml and
artifact verification for the anti-sybil
rules; maintainer review of this entry is the trust gate.
Fallback: split the legs locally
If you have a host with Sigstore egress (a jump box, CI runner, or hotspot), you can run the signing leg there instead of in Actions:
aicr evidence publish signs the pointer, so commit it directly at its
canonical per-source path, not flat. The command logs the exact
destination (copyTo=…) — copy the pointer there:
The flat commit-then-CI-sign flow above is only for the unsigned case: a
signed pointer already has the signer the <src> segment derives from, so
it goes straight to its nested path and needs no relocation. The bundle is
content-addressed, so its bytes (and bundle.digest) are identical regardless
of which host ran which leg — but the committed pointer path still depends on
the signer: <src> is derived from your signing identity, so a different
signer lands the pointer under a different <src> directory.
See also
- ADR-007: Verifiable Recipe Test Evidence — trust model and bundle format.
aicr evidenceCLI reference —sign,publish,verify.