CI integration
Relay in CI — gate the pipeline on evidence, not on green
relayevals verdict exits 0 on PASS, 1 on BLOCK, 2 on UNRESOLVED — so it gates a pipeline with no glue code. Setup for GitHub Actions and any other runner, keys included.
Relay gates a pipeline with its exit code: relayevals verdict exits 0 on PASS, 1 on BLOCK, and 2 on UNRESOLVED. Branch protection acts on that directly — no glue code, no report parsing.
There is a second half, and it is easy to leave out. relayevals ci report is what puts a run in your team console. The gate works without it — the build goes red, the pull request is blocked, everything a solo developer needs happens. But runs, catches, catch rate and per-repo health all read from what ci report sends, so a workflow that omits it produces a working gate and an empty dashboard, with nothing anywhere to explain the gap. The action below does it for you. If you write the job by hand, do not skip it.
The short way: the published Action
name: relay
on: [pull_request]
permissions:
contents: read
id-token: write # traded for a credential that expires in minutes
jobs:
verify:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0 # the merge base; a shallow clone has none
- run: curl -fsSL https://relayevals.com/releases/0.6.70/install.sh | bash
# Your tests, wrapped so the result is signed evidence rather than a log.
# continue-on-error: a red suite is a verdict, not a crash.
- name: Tests, with a signed receipt
continue-on-error: true
run: |
relayevals receipt run --name tests --producer ci-default --kind test \
--surface 'src/**' --coverage lcov:coverage/lcov.info -- npm test
# Gates the pull request AND reports the result to your console.
- uses: relay-evals/verify-action@v1
with:
version: "0.6.70"
fail-on-verdict: false # observe it for a week, then turn this onThe action installs the pinned CLI, exchanges the job's OIDC token for a 15-minute signing credential, verifies the changed lines against the receipts present, reports the result to your console, and then exits with the verdict. There is no secret to add to your repository — the private key is generated inside the job and never leaves it. It is a composite action that runs the published CLI and nothing else, so what executes in your job is the same artifact you can download and checksum.
Two things in that job are not optional, and both look like noise until they bite:
- The receipt step is yours to write. The action does not run your tests. A verdict is a computation over receipts, so a job that produces none is a verdict about nothing — every changed line unverified, every run blocked. Wrap whatever command you already run.
continue-on-erroron that step.receipt runexits with your test command's exit code. Without it a red suite ends the job before Relay reaches a verdict, and a red suite is exactly the case you installed this to see.
fail-on-verdict: false is there so installing the action cannot turn a passing build red on day one. Watch it for a week, then set it to true and mark the check required in branch protection — until you do, Relay reports without enforcing.
The manual way: any CI, your keys
The action is GitHub Actions only. Anywhere else, two values need to exist on the runner:
RELAY_AUTHORITY_PUBLIC_KEY— the PEM public key of your CI authority. Not sensitive; it is the trust root your team pre-provisions.RELAY_PRODUCER_PRIVATE_KEY— the PEM private key that signs receipts from this runner. Sensitive: keep it in the CI provider's secret manager.
Bootstrap once per job:
relayevals setup --profile ci --non-interactive \ --authority-public-key "$RELAY_AUTHORITY_PUBLIC_KEY" \ --producer-key-env RELAY_PRODUCER_PRIVATE_KEY
Deliberate properties: it is idempotent on a warm runner; it refuses to run without --non-interactive because pipelines must never wait on a TTY; and it never generates keys itself — an authority you did not provision cannot be quietly substituted.
On GitHub you do not need either secret: relayevals ci auth trades the job's OIDC token for a short-lived credential and writes the keys to the runner, which is what the manual job below uses.
Produce evidence, then gate
relayevals receipt run --name tests --producer ci-default --kind test \ --surface 'src/**' --coverage lcov:coverage/lcov.info -- npm test
relayevals verdict "$PR_TITLE"
relayevals ci report
Three decisions worth making deliberately:
- UNRESOLVED (exit 2) is not a pass. It means Relay could not determine an answer. A gate that only fails on exit 1 treats "I don't know" as success — fail on any non-zero unless you have a specific reason.
- Re-run the verdict in CI; never trust a committed verdict artifact. Verdict artifacts are unsigned by design — the
decision_hashis an integrity check, not a signature. Receipts are the transferable, signed evidence; the verdict is a computation over them that CI performs itself. - Report after the gate, unconditionally.
verdictexits non-zero on BLOCK, so a reporting step that runs only on success records nothing but passes.ci reportnever fails a build by design — an outage of ours must not become an outage of yours.
The manual job, written out
name: relay
on: [pull_request]
permissions:
contents: read
id-token: write
jobs:
verify:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- run: curl -fsSL https://relayevals.com/releases/0.6.70/install.sh | bash
- id: auth
run: relayevals ci auth
- run: |
relayevals setup --profile ci --non-interactive \
--producer-key @${{ steps.auth.outputs.producer-key }} \
--authority-public-key @${{ steps.auth.outputs.authority-key }}
# A red suite is a verdict, not a crash. Without this, a failing test
# ends the job before Relay ever reaches one.
- name: Tests, with a signed receipt
continue-on-error: true
run: |
relayevals receipt run --name tests --producer ci-default --kind test \
--surface 'src/**' --coverage lcov:coverage/lcov.info -- npm test
# THE GATE. PASS exits 0, BLOCK exits 1, UNRESOLVED exits 2.
- name: Verdict
env:
PR_TITLE: ${{ github.event.pull_request.title }}
run: relayevals verdict "$PR_TITLE"
# always(), because BLOCK exits 1. Without it the only runs this team
# would ever see in its console are the ones that passed.
- name: Report to the team console
if: always()
run: relayevals ci reportFour details that matter:
fetch-depth: 0— a shallow clone has no merge base, so committed divergence cannot be measured. Relay says so inwhy[]rather than silently under-counting, but you want the real number.if: always()on the report step — without it,verdictexiting 1 on a BLOCK skips the report, and the only runs your console ever shows are the ones that passed.- The PR title goes through an env var, never interpolated into the command. A PR titled
"; curl … | sh #would otherwise run in your CI. - Upload
.relay/receipts/as a build artifact if you want the evidence to outlive the job — receipts verify anywhere, without secrets.
Commit the policy
CI reads relay.policy.json from the repository root — it decides which receipt kinds are mandatory and whether an uncovered changed line blocks. relayevals setup writes it on a developer machine; commit it, and it is reviewed in pull requests like any other code. If a policy edit lands without a human accepting it, the verdict path refuses until someone runs relayevals policy accept in a terminal.
With coverage_evidence: "measured" in the committed policy, a declared surface the coverage report contradicts becomes SURFACE_UNTESTED and the build fails — instead of passing on a declaration nobody checked.