How it works

vci lets CI skip a test that a trusted person or agent already ran on exactly the same code. Everything else runs.

When a test passes on your machine, vci run records every file, package and environment variable it used, and signs that record with your SSH key. The signed record is an attestation. In CI, vci plan checks the signature, re-hashes every recorded input from CI's own checkout, and skips the test only if the signer is trusted and nothing has changed. In every other case the test runs.

vci skips or runs whole units: a test file for Vitest, pytest, Rails and RSpec, a package for Go, a test target for Cargo.

What an attestation is

An attestation is a signed list of everything one unit depended on when it passed, stored as hashes rather than contents:

The fields of one attestation: unit, project and runner, repository root commit, toolchain versions, an input root over hundreds of files, listings and probes, hashed environment variables, the result, issue and expiry dates, and the SSH signature. Each input is a BLAKE3 hash or 'absent', so CI can hash the same paths in its own checkout: the same root means skip, any difference means run.

vci finds a unit's inputs with collectors: hooks loaded into the test runner that record each file, directory, package and variable the test touches. (Rust has no such hook, so for Cargo the inputs are decided conservatively from the source instead.)

When a unit passes and did nothing vci cannot observe, vci run builds an in-toto statement and signs it with your SSH key (SSHSIG, namespace vci-attest) inside a DSSE envelope. The statement records:

Attestations are keyed by unit and inputs, not by commit. So they survive rebases and apply on every branch.

When vci run refuses to attest

If vci cannot vouch for a unit, it signs nothing, and the unit runs in CI. That happens when:

Each adapter has its own list; see test runners.

Where attestations live

vci has no storage format of its own. By default attestations are git metadata, pushed and fetched like any other ref. Since 2.8.0 they can live in a SQLite file, on PostgreSQL or in a Turso database instead (choosing a store); the checks below are the same whichever store holds them, since no store is trusted.

Your git remote holds ordinary branches plus refs/meta/main, managed by git-meta. Inside refs/meta/main each unit's path, such as path:spec/models/order_spec.rb, holds keys of the form vci:attestation:unit:signer:inputs whose value is the signed envelope. One value per unit, signer and inputs. Metadata is not trusted on its own: a value causes a skip only if its signature verifies and every input matches, and deleting one only makes a test run.

Each attestation is one git-meta value attached to the unit's path:

git meta get path:src/b.test.ts

Nothing in the store is trusted. Anyone with push access can write or delete metadata, exactly as with any other ref. A value that is deleted, overwritten, pruned or never fetched only means the unit runs. Only a correctly signed envelope for exactly this unit, repository, toolchain and inputs skips anything.

How CI decides

vci plan asks the same questions of every unit, and skips it only when every answer is yes:

Five checks for every test unit: signed by a key in main's allowed_signers; not expired and within max_ttl; same project, runner and toolchain versions; every recorded input re-hashed to the same root; hashed environment variables (set, hash, built-in) with the same hashes. All yes means SKIP, recorded in the audit log with who vouched. Any no means RUN, which is also the verdict on any error, a missing store, a refused unit, or a ref in no_skip_refs.

The core rule: fail open

The default verdict for every unit is RUN. A unit gets SKIP only when one attestation passes every check. Any error, doubt or unrecognised behaviour means it runs. Each candidate is checked inside an error boundary, so even a crash while checking is a failed check, never a skip.

Every check, in order

A file matching policy.never_skip always runs. Otherwise each stored candidate is checked in this order, and the first candidate to pass every check means skip:

#CheckPasses when
1envelopeit parses as DSSE with the in-toto payload type
2signaturethe SSH signature covers the exact stored payload bytes
3signerthe key is in the base commit's allowed_signers, within its validity dates
4statementpredicate type and subject match
5expirynot expired, not issued in the future, lifetime within max_ttl
6repo, test id, adapter, argvall match this repository and unit
7toolchainlanguage and runner versions match, and OS and architecture satisfy policy.platform
8env-configthe [env] configuration at the base commit is the one attested under
9resultthe unit passed, untainted; declared inputs and waivers still match base policy
10global inputsevery global entry re-hashes to the same value in this checkout
11inputsevery file, symlink, directory listing and absence re-hashes to the same value
12externalsevery package is installed in exactly the attested version
13envevery hashed variable has the same value

vci explain prints the first failed check for each candidate.

As policy.platform relaxes check 7, policy.libraries = "any" (Rails and pytest) relaxes two checks, on the base branch's say-so: check 12 matches a native library such as libvips by name instead of file hash, and check 7 stops comparing the SQLite, libyaml, time zone data and OpenSSL versions.

Trust comes from the base branch

The base branch is the branch a pull request merges into, usually main. CI takes its rules only from the base commit, the tip of that branch, never from the pull request:

The CI runner takes keys and policy, .vci/allowed_signers and vci.toml, only from main, the base branch, where changes need review. From the pull request branch it takes only code and attestations; a key or a loosened vci.toml added in the pull request is ignored for trust decisions.

The base ref must be the base branch's commit. If CI passes a ref the pull request controls (its head, its merge commit or HEAD), keys and policy come from the code under test. The action passes github.event.pull_request.base.sha by default, and vci plan warns when the base commit is HEAD or contains it.

Threat model

Protected against

Not protected against

Limit what a signer can get wrong. Keep allowed_signers short, use hardware-backed keys, and set valid-before on entries. List tests whose inputs no collector can see in policy.never_skip.

The backstop. Keep your default branch in policy.no_skip_refs (the vci init default). Every merge then gets a full run, which catches a false claim after the fact.

CI as a trusted signer. CI can attest its own runs (vci ci --key, the action's signing-key), and policy.no_skip_refs_except lets that key's attestations skip on main too. Main then skips only what CI itself already ran on the same inputs, but whoever holds that key can make main skip anything until its line is removed. Keep it in a GitHub Environment limited to the default branch, use one key per repository, keep its ttl short, and rotate it by replacing its line in .vci/allowed_signers. See attesting in CI.

CI without a key. Since 2.0 a GitHub Actions job can attest keylessly (vci ci --oidc, the action's oidc: true): its OIDC token gets a ten-minute Sigstore certificate for a key that never leaves the job's memory, so there is no secret to leak or rotate. Trust moves to .vci/identities.toml on the base branch, which names the repository, workflow file, ref and event whose runs count, and to the committed Sigstore trust root; verification is offline and checks no transparency log. Any step of the attesting job can still request its token, so the identity main skips on must match only main's runs. See keyless attestation.

Go deeper

The reference has every recorded input, every refusal, and the known limitations per adapter. Design notes are in docs/.