Using exit codes in CI¶
The exit-code contract¶
verify, migrate and export share one exit-code contract: 0 means
clean, 1 means something was found — a verification finding, a missing
database, a migration that was refused — and 2 means the database could not
be read at all, or the requested output could not be produced. revoke is
the exception: it always reports what it found and exits 0, whether or not
the subject affected any model, and only exits 2 if the database can't be
read.
The distinction between 1 and 2 matters for how a pipeline should react. A missing database is exit 1 — the path was checked and there was nothing there:
Audit log FAILED verification: 1 finding across 0 entries.
- [missing_database] tables: no lineage database at does-not-exist.db
head: 0000000000000000000000000000000000000000000000000000000000000000
A path that can't be opened at all — a directory, a permission error — is exit 2, because nothing was actually read:
Gating a pipeline on verification¶
- name: Verify the lineage audit log
run: |
pip install git+https://github.com/KaranamLokesh/consentml.git
consentml verify --db lineage.db --expected-head "${{ secrets.CONSENTML_ANCHOR }}"
ConsentML is pre-release and has no PyPI release yet, so CI needs to install
from source as above; swap in pip install consentml once a version is
published.
A nonzero exit from this step fails the job, the same way any other run:
step would. See the anchoring guide for what
--expected-head checks and where the hash it takes comes from.
Why the anchor belongs in secrets, not the repo¶
An anchor is only useful if whoever can rewrite the lineage database can't
also rewrite the value being checked against it. Store the anchor next to
the database it protects — a file checked into the same repo, a column in
the same table — and the two move together: an attacker who rewrites the
database from genesis recomputes a new head and updates the stored anchor to
match in the same act, and --expected-head passes cleanly over history
that never happened. A CI secret is a separate, more restricted store than
the repository or the database it's checking, which is what makes rewriting
one without also being able to rewrite the other actually hard.
Exporting dossiers in CI¶
--format json produces the same dossier content as the default HTML
export, structured for a pipeline step to parse rather than a person to
read; --out - writes it to stdout instead of a file, for piping into
whatever handles revocation requests downstream.
Export follows the same exit-code contract as verify, and it keeps
writing the dossier even when verification fails — refusing would leave
whoever's handling a live request with nothing to file, and a document
whose own first section reports the failure is more useful than no document
at all. Against a database with a tampered entry, export still prints the
full dossier and exits 1:
The tail end of that JSON — the same verification block consentml verify
reports on its own — carries the failure:
"verification": {
"findings": [
{
"code": "entry_hash_mismatch",
"detail": "entry 1 hash does not match its contents",
"entry_id": 1
},
{
"code": "run_modified",
"detail": "run 852c5f43-cdfd-4b62-9d6c-02d9e7cbbbe0: model_name in training_runs ('m1') differs from the logged value ('m1-tampered')",
"entry_id": 1
}
],
"head_hash": "67ab7e46f61b46945676d0a7bbaf8641fd11f6a868145c2e7717020c1a2fcfe5",
"n_entries": 2,
"n_legacy_runs": 0,
"ok": false
}
(exit code 1 — the full dossier, 51 lines of JSON, was still written before the process exited)
The exit code is what a pipeline should branch on; the document itself is for the person or system handling the request, not for the pipeline to inspect.