Menu

Contact the founders

Bugs, feedback, or something the docs did not answer — arnav@relayevals.com. We read every message.

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 on

The 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-error on that step. receipt run exits 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_hash is 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. verdict exits non-zero on BLOCK, so a reporting step that runs only on success records nothing but passes. ci report never 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 report

Four details that matter:

  • fetch-depth: 0 — a shallow clone has no merge base, so committed divergence cannot be measured. Relay says so in why[] rather than silently under-counting, but you want the real number.
  • if: always() on the report step — without it, verdict exiting 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.