QED in CI¶
qed check is the pipeline-shaped entry point: one proposal in, one verdict
out, and an exit code the build can act on (SPEC §12.5).
Exit codes¶
| Code | Meaning | What a pipeline usually does |
|---|---|---|
0 |
ALLOW |
continue |
2 |
BLOCK |
fail |
3 |
REQUIRE_APPROVAL |
fail, or wait for a human |
1 |
QED could not decide | fail, and page somebody |
1 is separate on purpose, and it is the distinction worth building the
pipeline around. A runner with no signing key, an unreadable proposal or an
unreachable instance has not been refused anything. Reading that as a BLOCK
reports a policy violation no policy produced; reading it as 0 lets a
misconfigured runner look exactly like a policy that allows everything.
In shadow mode the exit code is always 0. The decision is still recorded,
and the output says so. A pipeline that failed in shadow mode would make shadow
mode useless for its one purpose — finding out what a policy would do before
anybody depends on it.
qed check proposal.json --server "$QED_URL" --adapter-id "$QED_ADAPTER_ID"
case $? in
0) echo "allowed" ;;
2) echo "blocked by policy"; exit 1 ;;
3) echo "waiting for a human"; exit 1 ;;
*) echo "QED could not decide"; exit 1 ;;
esac
Two modes, and why --server is the better one¶
--server — the team's instance decides¶
export QED_URL=https://qed.internal
export QED_ADAPTER_ID=adp_ci
export QED_ADAPTER_SECRET=... # from your CI secret store
qed check proposal.json
The request is HMAC-signed the way every other adapter signs (SPEC §13), the instance decides against the policy bundle it has loaded, and the evidence is appended to its chain. The reference comes back on stdout:
Three things follow from this, and they are the reason to prefer it:
No signing key in CI. A runner holding an evidence signing key is a runner
that can mint evidence, and the chain's value rests entirely on that not being
something anybody can do casually. --server needs an adapter secret, which
grants the ability to ask.
The evidence outlives the build. A chain in $GITHUB_WORKSPACE is deleted
with the job. The decision that gated a production deploy should not have a
six-minute lifetime.
One policy bundle. The rules that gate CI are the rules that gate
everything else, and policy_hash in the evidence names them.
Register the adapter on the instance first (SPEC §12.6); an unknown adapter is
refused with AG-IDN-005, not quietly allowed.
Local, with an artifact¶
No instance to reach? Decide locally and leave the chain behind as a build artifact:
qed keys init --dir .qed
qed check proposal.json --dir .qed --evidence-out qed-evidence.jsonl
qed-verify qed-evidence.jsonl --keys .qed/public --instance-id qed-local --require-complete
--require-complete belongs in a pipeline specifically because a pipeline reads
the exit code and nobody reads the summary. Without --instance-id the genesis
binding does not run, and the verifier exits 0 with a pass that is narrower than
it looks; with --require-complete a missing id fails the build instead.
--evidence-out writes the whole chain, not just this verdict.
qed-verify walks linkage from genesis, so a file holding the last two records
is a file that cannot be verified. A CI chain is short by nature — it starts at
genesis in a fresh workspace — so there is nothing to trim.
Upload it as an artifact and the build is self-evidencing: anyone with the public key can check what was decided, without the runner, the repository or QED itself.
GitHub Actions¶
name: governed deploy
on: [pull_request]
jobs:
gate:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
# This installs a placeholder, not QED: the name is reserved and the
# real package is not published, so PyPI serves a 0.0.0 holding one
# README. Until it is released, the step is
# `pip install git+https://github.com/valqore/qed`.
- name: Install qed
run: pip install valqore-qed
- name: Ask QED whether this may deploy
env:
QED_URL: ${{ vars.QED_URL }}
QED_ADAPTER_ID: ${{ vars.QED_ADAPTER_ID }}
QED_ADAPTER_SECRET: ${{ secrets.QED_ADAPTER_SECRET }}
run: |
qed check .qed/deploy-proposal.json --json | tee verdict.json
- name: Keep the verdict with the run
if: always()
uses: actions/upload-artifact@v4
with:
name: qed-verdict
path: verdict.json
--json prints the instance's own response document, which is the thing to
keep: the verdict, the rules that fired, policy_hash, and evidence_ref.
To wait on an approval rather than fail, branch on 3 and poll
GET /v1/approvals/{id} — the SDK's Qed.wait() does exactly that
(docs/api.md).
Writing the proposal¶
A proposal is a JSON document describing one action (SPEC §3). examples/proposals/
has three; the fields that matter most in a pipeline are:
{
"action": {"kind": "deploy", "name": "helm.upgrade", "target": "k8s:prod/payments"},
"context": {
"environment": "prod",
"intent": "release 2026.10.3",
"blast_radius_hints": {"writes": true, "scope": "service"}
},
"principal": {"human_hash": "...", "token_jti": "ci"}
}
context.intent is worth filling in even though nothing requires it: several
baseline rules route a production write with no stated intent to approval,
because an approver with no reason in front of them has nothing to judge.
In this repository's own CI¶
.github/workflows/ci.yml runs the week-1 and week-2 exit criteria as shell
steps rather than only as pytest: qed check → qed export → qed-verify,
then the same again with a tampered record to prove the verifier still refuses
it. The postgres job repeats the end-to-end run against a real PostgreSQL,
the anchors job publishes a checkpoint to a real S3 server and then tries to
delete it, and the deploy job builds the container, renders the Helm chart
and runs qed demo inside the frozen binary. Those steps are the closest thing
in the repo to a worked example of this page.