Get started

Install vci, enable it in one repository, sign your first test run and see the plan skip it.

vci lets CI skip a test that a trusted person or agent already ran against exactly the same inputs. You will:

  1. install the vci binary;
  2. set up your repository, once;
  3. run and sign tests as you work;
  4. let CI skip what is already signed (GitHub Actions).

Key ideas

These four words come up throughout the docs:

TermMeaning
unitThe smallest piece of your test suite vci can skip, such as one test file or one Go package.
attestationA signed record of one unit's run: what ran, a hash of every input it used, the result, and who signed it.
collectorThe plugin vci loads into your test runner to record what each unit reads while it runs.
base branchThe branch a pull request merges into, usually main. CI takes its trusted signers and policy from there, never from the pull request.

What counts as a unit depends on the test runner:

The skippable unit per runner: Vitest and pytest a test file, Go a package, Cargo a test target such as crates/api#test:orders, Rails a Minitest file, RSpec a spec file.

Requirements

ToolNeeded for
giteverything
ssh-keygen (OpenSSH 8.1+) and an Ed25519 SSH keysigning attestations
Rust toolchain (cargo, 1.85+)building the vci binary (not needed with Homebrew)
Node 22.15+ and Vitest >=3.2 <6Vitest projects
uv and pytest 9pytest projects
Go (tested with 1.26.2)Go projects
cargo/rustc installed through rustupRust projects
Ruby (the exact version in .ruby-version), Bundler, Rails 8 with Minitest or RSpecRails projects

macOS and Linux are supported.

Install

With Homebrew (macOS and Linux). This installs a prebuilt binary with the collectors included, so there is nothing else to set up:

brew install pandelisz/tap/vci

Upgrade later with vci update (it runs brew update and brew upgrade pandelisz/tap/vci), or brew upgrade vci.

Without Homebrew

The install script clones the repository to ~/.local/share/vci and builds it with cargo, so it needs a Rust toolchain:

curl -fsSL \
  https://raw.githubusercontent.com/PandelisZ/cryptographically-verifiable-ci-runner/main/scripts/install.sh \
  | sh

To pin a version, set VCI_REF to a tag or commit before running the script. Or build by hand:

git clone \
  https://github.com/PandelisZ/cryptographically-verifiable-ci-runner.git \
  ~/.local/share/vci
cargo install --locked --path ~/.local/share/vci/crates/vci-cli

Then tell vci where its collectors live. Add this to your shell profile:

export VCI_PY_PLUGIN="$HOME/.local/share/vci/py/pytest-plugin"
export VCI_JS_PLUGIN="$HOME/.local/share/vci/js/vitest-plugin"
export VCI_RUBY_COLLECTOR="$HOME/.local/share/vci/ruby/vci-collector"

Check the install

vci --version

Keep it current

vci update           # install the latest release the way vci was installed
vci update --check   # only compare: exit 0 when current, 1 when there is an update

vci update uses Homebrew for a Homebrew install and re-runs the install script at the release's tag for the script's install. A release binary downloaded by hand is replaced in place after its SHA-256 is checked; collectors installed beside it are not updated.

Set up a repository

Do this once, in the repository you want to speed up. Pick your test runner in the first step; the other tabs on these pages follow your choice.

  1. Create the config and trust file

    vci init --adapter vitest --key ~/.ssh/id_ed25519.pub --no-install
    vci init --adapter pytest --key ~/.ssh/id_ed25519.pub
    vci init --adapter go --key ~/.ssh/id_ed25519.pub
    vci init --adapter cargo --key ~/.ssh/id_ed25519.pub
    vci init --adapter rails --key ~/.ssh/id_ed25519.pub
    # RSpec runs under the Rails adapter; the runner is detected
    vci init --adapter rails --key ~/.ssh/id_ed25519.pub

    This writes three files:

    FileWhat it is for
    vci.tomlPolicy and environment settings (reference).
    .vci/allowed_signersWho may sign attestations. Your key is the first entry.
    .git-metaWhere attestations are exchanged: your origin remote, or the URL you pass with --meta-url. Without either, it is not written.

    By default attestations travel through git, on refs/meta/main, and need no server. To keep them in a SQLite file, PostgreSQL or Turso instead, see choosing a store once this setup works.

    Add --project DIR when the project is not at the repository root, and --principal you@example.com to name the key's owner. For Vitest, --no-install stops vci init from running npm install for the @vci/vitest collector.

  2. Add every person or agent who may attest

    One line each in .vci/allowed_signers:

    alice@example.com namespaces="vci-attest" ssh-ed25519 AAAAC3Nza...
    build-agent namespaces="vci-attest",valid-before="20270101" ssh-ed25519 AAAAC3Nza...

    Give agents their own key, not a person's key, and an expiry (valid-before).

  3. Merge the files to your default branch

    git add vci.toml .vci/allowed_signers .git-meta
    git commit -m "Enable vci"

    This step matters. CI reads policy and trusted signers from the base commit, never from the pull request. Until these files are on main, nothing is skipped.

  4. Pin your toolchains

    An attestation is only accepted where the toolchain matches. Pin the same versions locally and in CI:

    RunnerPin
    VitestNode major version, package-lock.json
    pytestfull Python version in .python-version (a uv-managed build such as 3.14.4), uv.lock, the uv version, UV_PYTHON_PREFERENCE=only-managed
    Goexact Go version (go version must print the same on both sides), go.sum, [env] set = { TZ = "UTC", CGO_ENABLED = "0" } in vci.toml (vci sets them on both sides)
    Rustexact release in rust-toolchain.toml (a rustup build, not Homebrew's), Cargo.lock, no mode = "loose" in [env]
    Railsexact Ruby in .ruby-version (and ruby-version: in ruby/setup-ruby), Gemfile.lock with the x86_64-linux platform (bundle lock --add-platform x86_64-linux), gem "tzinfo-data", [env] set = { TZ = "UTC" } in vci.toml, and CI left out of [env] (so the generator's config.eager_load = ENV["CI"].present? is false on both sides)
    RSpeceverything in the Rails row, plus options in .rspec (vci never reads ~/.rspec, $XDG_CONFIG_HOME/rspec/options or .rspec-local), and SPEC_OPTS listed in [env] hash (or set) if you use it

    Environment variables are three lists in vci.toml's [env]: set (vci sets them in every test process, on every machine: vci init writes set = { TZ = "UTC" }, so nothing needs exporting), hash (taken from the machine; a change re-runs the test) and ignore (visible, never hashed). Every other variable is removed before the tests run, apart from a short built-in list per runner. vci env prints the effective lists. Details: [env]; per runner: test runners.

Daily use

Three commands cover the daily loop:

# Run some tests locally and sign what was run.
vci run tests/test_b.py tests/test_c.py --key ~/.ssh/id_ed25519

# See what CI would do.
vci plan --base-ref origin/main

# Share the attestations, then push your branch as usual.
vci push
git push

vci run with no paths runs and attests every unit. Paths are relative to the current directory, and what you pass depends on the runner:

AdapterUnitWritten as
vitesttest filesrc/b.test.ts
pytesttest filetests/test_b.py
gopackage./b
cargotest targetcrates/b#lib, crates/b#test:name, crates/b#doc
railsMinitest or RSpec filetest/models/b_test.rb, spec/models/b_spec.rb

Set VCI_SIGNING_KEY=/path/to/key to leave out --key. A .pub path works when the private half is in ssh-agent, hardware keys included.

When a test is not skipped

Ask why:

vci explain src/b.test.ts --base-ref origin/main
src/b.test.ts: RUN (no valid attestation (1 candidates; best failed inputs: entry:fixtures/b.json))
candidate 1: git-meta path:src/b.test.ts vci:attestation:3b9f…:b6c35e2a042def43:2002a8ce…
  signer: alice@example.com SHA256:tsNeKgQt70OZTfF07nIc2YrOeC6pHt7EvMF3r8USWEs
  issued 2026-09-28T00:26:18Z, expires 2026-10-12T00:26:18Z
  failed check: inputs
    entry:fixtures/b.json
      expected: file exec=false size=24 blake3=b67128f7…
      actual:   file exec=false size=25 blake3=342a1f57…

expected is what the attestation recorded. actual is what this checkout has now. Here a fixture changed after the test was attested, so the test must run again.

For agents

An agent uses the same commands as a person. Give it:

Next