Use relate in CI
Run relate in GitHub Actions with a pinned, signature-checked binary, usage metrics off, and a Cypher check on a read-only store that gates the job.
cliagents
Before you start: Install relate, Script the CLI
This guide shows you how to run relate in a GitHub Actions job: install one exact release and check its signature, keep usage metrics off, and gate the build on a Cypher statement One unit of Cypher that relate parses, plans and runs, such as a MATCH … RETURN read or a CREATE write; a script holds several, separated by ;. Read more in the glossary that returns no rows when the build may pass. The example check is the advisory gate from the dependency blast radius recipe, run against a store committed to the repository and opened --read-only.
Prerequisites
- A GitHub repository with Actions enabled. The workflow runs on
ubuntu-24.04, whose image (version 20261101.360.1) includesjq1.7 and the OpenSSH client. - A store in the repository for the check to read, such as a
.rlfile. The steps build a small one,deps.rl. - relate on your
PATH, to run the check locally first (Install relate).
Build a fixture store
The check reads a dependency graph: package versions that DEPENDS_ON each other, and advisories that affect some of them, as in the recipe's data model. This writes a small one into a new single-file store A store kept in one .rl file that holds the snapshot and the WAL together; the layout for a --database path ending in .rl. Read more in the glossary, deps.rl, with invented package names. In a real repository the store is built by your own tooling and committed:
relate query --database ./deps.rl --mode envelope <<'EOF'
CREATE (app:Version:Root {key: 'NPM/[email protected]', name: 'team-api', version: '1.4.0'}),
(router:Version {key: 'NPM/[email protected]', name: 'route-kit', version: '2.3.1'}),
(parser:Version {key: 'NPM/[email protected]', name: 'path-match', version: '0.1.7'}),
(signer:Version {key: 'NPM/[email protected]', name: 'token-sign', version: '8.5.1'}),
(app)-[:DEPENDS_ON {requirement: '^2.3.0'}]->(router),
(router)-[:DEPENDS_ON {requirement: '0.1.7'}]->(parser),
(app)-[:DEPENDS_ON {requirement: '^8.5.0'}]->(signer),
(parser)-[:AFFECTED_BY]->(:Advisory {id: 'ADV-2026-0007', title: 'Backtracking on crafted paths', cvss3: 7.5}),
(signer)-[:AFFECTED_BY]->(:Advisory {id: 'ADV-2026-0012', title: 'Weak default algorithm', cvss3: 8.1}),
(router)-[:AFFECTED_BY]->(:Advisory {id: 'ADV-2026-0019', title: 'Verbose error pages', cvss3: 4.3});
EOF{"columnTypes":[],"columns":[],"notifications":[],"planCache":"miss","rows":[],"timings":{"elapsed_ms":2.5163339999999996,"execute_ms":2.190625,"lex_parse_ms":0.017499999999999998,"lower_ms":0.019792,"parse_ms":0.30762500000000004,"plan_ms":0.012125,"result_generation_ms":0.000125,"rewrite_ms":0.0012920000000000002},"truncated":false}Write the check as a statement
A gate is a statement whose rows are the findings: no rows, the build passes. This one lists every advisory with a score of at least $minCvss that a root package can reach, with the shortest chain to it, minus the ids in $allow that a reviewer accepted. Keep it in the repository as gate.cypher:
cat > gate.cypher <<'EOF'
MATCH p = SHORTEST 1 (r:Root)-[:DEPENDS_ON]->{0,12}(v:Version)
MATCH (v)-[:AFFECTED_BY]->(a:Advisory)
WHERE a.cvss3 >= $minCvss AND NOT a.id IN $allow
RETURN a.id AS advisory, a.cvss3 AS cvss, v.key AS version,
[n IN nodes(p) | n.name] AS path
ORDER BY cvss DESC, advisory, version;
EOFThe thresholds are parameter A named value a statement refers to as $name and the caller binds separately, such as with --param name=value on the CLI. Read more in the glossary, so the workflow changes them with --param and the statement stays the same file. The recipe explains the pattern and runs it on 89 real package versions.
Turn the findings into an exit code
relate's own exit code says whether the statement ran: 0 for success, including an empty result, 6 for a rejected statement, 1 for a store that cannot be opened, 5 for one another process holds. The findings are rows, so a second command turns rows into a failure. --mode json prints the result as one JSON object with the rows in data, beside the statement's timings, and jq -e '.data | length == 0' exits 1 when data has anything in it:
relate query --database ./deps.rl --read-only --mode json --max-rows 0 --timeout 30s --param minCvss=7.5 --param 'allow=[]' < gate.cypher > findings.json
echo "relate exit=$?"
cat findings.json
jq -e '.data | length == 0' findings.json
echo "gate exit=$?"relate exit=0
{"data":[{"advisory":"ADV-2026-0012","cvss":8.1,"path":["team-api","token-sign"],"version":"NPM/[email protected]"},{"advisory":"ADV-2026-0007","cvss":7.5,"path":["team-api","route-kit","path-match"],"version":"NPM/[email protected]"}],"planCache":"miss","timings":{"elapsed_ms":1.26275,"execute_ms":1.0196669999999999,"lex_parse_ms":0.025,"lower_ms":0.060458000000000005,"parse_ms":0.16883299999999998,"plan_ms":0.067625,"result_generation_ms":0.000375,"rewrite_ms":0.007208}}
false
gate exit=1Two findings fail the gate; the 4.3 advisory is under the threshold. Every flag on that command is there for CI:
| Flag | Why |
|---|---|
relate query … < gate.cypher | query reads the statement from stdin. Without the word query, a redirected file is not read: relate prints its help and exits 0 |
--read-only | The check cannot change the store, and a write in the statement fails with exit code 6 |
--mode json | One JSON object with the rows in data, whatever stdout is; with --max-rows 0 its shape does not change under agent mode either |
--max-rows 0 | Every finding is printed, even when the job runs under an agent harness, where the default caps output at 100 rows |
--timeout 30s | A statement that runs longer is cancelled with exit code 7 instead of holding the job |
Script the CLI covers each one, and Exit codes and errors lists every exit code.
Accept reviewed findings
A finding a reviewer accepts goes into $allow, in the workflow or a file beside gate.cypher. With both ids allowed, the result is empty and the gate passes:
relate query --database ./deps.rl --read-only --mode json --max-rows 0 --timeout 30s --param minCvss=7.5 --param 'allow=["ADV-2026-0007","ADV-2026-0012"]' < gate.cypher > findings.json
jq -e '.data | length == 0' findings.json
echo "gate exit=$?"true
gate exit=0Keep usage metrics off
The relate CLI sends anonymous telemetry The anonymous usage metrics the relate CLI sends: on by default, off with DO_NOT_TRACK=1, RELATE_TELEMETRY=0 or relate telemetry off. Read more in the glossary unless a setting turns them off (Privacy and telemetry). DO_NOT_TRACK=1 in the job's environment turns them off for every step, sends nothing, and prints no first-run notice. relate telemetry status names the setting that decided:
DO_NOT_TRACK=1 relate telemetry statusdisabled (DO_NOT_TRACK)Add the workflow
The workflow installs relate 0.1.2 with the three checks install.sh makes, from the Pinned binary steps of Install relate: the signature on SHA256SUMS against the release signing key, the signed first line naming the release, and the binary's SHA-256. Releases up to and including 0.16.1 were published under the project's earlier name, so their assets, signer and manifest header say cypherlite; from 0.1.0 on they say relate, with the same key (Security). The gate's .data filter needs 0.1.0 or later, where --json wraps the rows in data. The release URLs are launch-day targets.
name: Graph check
on:
pull_request:
push:
branches: [main]
permissions:
contents: read
env:
DO_NOT_TRACK: '1'
RELATE_VERSION: 0.1.2
RELATE_ASSET: relate-linux-amd64
RELATE_SIGNER: relate-release
jobs:
gate:
runs-on: ubuntu-24.04
defaults:
run:
shell: bash
steps:
- uses: actions/checkout@v7
# The download, cached by version and asset; the checks below run on every job.
- name: Restore the relate download
id: relate-cache
uses: actions/cache@v6
with:
path: relate-download
key: relate-${{ env.RELATE_VERSION }}-${{ env.RELATE_ASSET }}
- name: Download relate
if: steps.relate-cache.outputs.cache-hit != 'true'
run: |
mkdir -p relate-download && cd relate-download
BASE="https://github.com/relatedb/relate/releases/download/v$RELATE_VERSION"
curl -sSfLO "$BASE/$RELATE_ASSET"
curl -sSfLO "$BASE/SHA256SUMS"
curl -sSfLO "$BASE/SHA256SUMS.sig"
- name: Verify and install relate
working-directory: relate-download
run: |
echo "$RELATE_SIGNER ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIHgv/GHY5mtv/HopH/J/T7KrTT7eOVFEG2ueT4iRKwbK $RELATE_SIGNER" > allowed_signers
ssh-keygen -Y verify -f allowed_signers -I "$RELATE_SIGNER" -n "$RELATE_SIGNER" -s SHA256SUMS.sig < SHA256SUMS
test "$(head -n 1 SHA256SUMS)" = "# $RELATE_SIGNER-version: v$RELATE_VERSION"
grep " $RELATE_ASSET\$" SHA256SUMS | sha256sum -c -
mkdir -p "$HOME/.local/bin"
install -m 0755 "$RELATE_ASSET" "$HOME/.local/bin/relate"
echo "$HOME/.local/bin" >> "$GITHUB_PATH"
- name: Check the version
run: relate --version
- name: Gate on reachable advisories
run: |
relate query --database ./deps.rl --read-only --mode json --max-rows 0 --timeout 30s \
--param minCvss=7.5 --param 'allow=[]' < gate.cypher > findings.json
jq . findings.json
jq -e '.data | length == 0' findings.jsonWhat each part does:
envapplies to every step:DO_NOT_TRACKturns usage metrics off, and the threeRELATE_values are the pin. Moving to a new release changes them together, in the same change that moves anyAGENTS.mdpin.shell: bashruns each step withbash --noprofile --norc -eo pipefail, so the first failing command, a failed check or a non-zero relate exit, fails the step. A step with noshellon an Ubuntu runner runsbash -e, withoutpipefail(GitHub's workflow syntax reference, read 2026-11-08).actions/checkoutv7 (as its README describes v7.0.1, the latest release on 2026-11-08) checks out the repository, store andgate.cypherincluded.- The cache (
actions/cachev6, as its README describes v6.1.0) keeps the three downloaded files under a key made of the version and the asset, so a new pin downloads again. The cache stores the files, not the trust: the verify step checks the signature, the header and the checksum on every job, cached or not, and needs no network. - The verify step stops the job at the first check that fails, before anything is installed.
- The gate step prints the findings to the log with
jq ., then fails the job when relate exits non-zero or when the result has rows.
Open the workspace store read-only
The checkout puts the store in the workspace, and every step that reads it passes --read-only: relate writes nothing, runs no checkpoint, and leaves the file byte for byte as committed. A .rl store opens read-only only while no process writes it, which holds in a job where nothing else opens it; otherwise the open fails with Failed to open Relate: storage locked: <path>, exit code 5.
A check that needs to write, such as one that derives data before it gates, works on a copy. --load ./deps.rl --database ./scratch.rl copies the store and opens the copy for writing; --load ./deps.rl alone loads it into an in-memory store that is itself read-only and gone when the step ends.
Verify
Run the gate locally against the committed store before pushing: exit code 1 and the findings on a failing graph, exit code 0 and an empty data array on a passing one, as in Turn the findings into an exit code. In the Actions log, the Check the version step prints:
relate --versionRelate v0.1.2
License: Apache 2.0 (binary)If it goes wrong
| What you see | Cause | Remedy |
|---|---|---|
The verify step fails at ssh-keygen -Y verify, with no Good "relate-release" signature line | The downloaded SHA256SUMS or its signature is not the release's, or a value in env does not match the release | Check the three RELATE_ values against the Pinned binary steps; never skip the check |
The verify step fails at test or sha256sum -c - | The manifest names another release, or the binary does not match its line | Delete the cache entry for the key and run again; a second failure means the download is not the release |
Failed to open Relate: storage error: database not found: <path>, exit code 1 | The store path is wrong, or the file was not checked out (Git LFS without lfs: true on the checkout) | Fix the --database path |
semantic error: … or syntax error: …, exit code 6 | gate.cypher does not parse or plan, or it writes on a read-only store | Run it locally with the same flags; the message names the problem |
Error: query timed out after <duration>, exit code 7 | The statement ran past --timeout | Bound the pattern, or raise --timeout |
findings.json holds at most 100 findings and a rowCap key, and stderr shows warning: result truncated to 100 rows … | --max-rows 0 is missing and an agent harness variable is set in the job | Add --max-rows 0 |
Related
- Fresh graph per test run and Dependency analysis in the capability catalog.
- Dependency blast radius: the gate on real dependency graphs, with its data.
- Install relate: the Pinned binary steps.
- Security: the signing key, its fingerprint, and what each check proves.
- Exit codes and errors: every exit code and the error object.
- Agent mode: the defaults a harness variable changes.