Skip to content

Tessera in CI

Tessera is one binary with no service behind it and no network calls, so it runs in any CI. The GitHub Action wires up everything on this page for GitHub; the sections below are for other CI systems, or for doing it by hand.

tessera affected [REV] lists the test files a change reaches, each with the reason it runs. The change is the working tree against REV (default HEAD), or the staged files with --staged.

$ tessera affected origin/main
1 of 40 test files selected (39 skipped) for changes since origin/main
test/repo.test.ts
$ tessera affected origin/main --explain test/repo.test.ts
test/repo.test.ts is selected (reached): describe("loadUser").it("returns the user") → loadUser → UserRepo

A test is selected when:

Kind Why
changed The change adds or edits the test file
always It is listed in the file given to --failed (tests that failed in an earlier run, one path per line)
reached It reaches changed code through calls, references, imports, or a contract the contract map links (a client in one service, the handler in another) within --depth steps (default 12). Removing or renaming a function selects the tests of its callers; deleting a file selects the tests of its importers; changing a contract definition (.proto, OpenAPI, GraphQL schema, SQL, configuration) selects the tests on both sides of each contract it declares. Code inside a function or class counts as part of it: a change in a callback, a constructor or a component’s render reaches the tests that use the function or class around it. In Go, every test of a package that imports the changed package, directly or through others, is selected, since a Go test runs only code its package imports; this covers calls through function values and interfaces that no call graph follows. In other languages without a deep-tier resolver (Python, Rust, C, C++, C#, Ruby, PHP), calls to a name with many definitions are followed by name
full-run The whole suite runs; the reason says why

The whole suite runs, with the reason, when the change touches:

  • a lockfile (package-lock.json, pnpm-lock.yaml, yarn.lock, Cargo.lock, go.sum, poetry.lock, uv.lock and others);
  • a build file (package.json, Cargo.toml, go.mod, pom.xml, any *.gradle or *.gradle.kts, Gradle version catalogs, pyproject.toml, requirements*.txt and requirements/, .npmrc, .yarnrc.yml, Makefile and others);
  • test runner, toolchain or CI configuration (jest.config.*, vitest.config.*, conftest.py, pytest.ini, .nvmrc, rust-toolchain.toml, .github/workflows/, .github/actions/ and others);
  • a fixture or snapshot directory (fixtures/, testdata/, __snapshots__/ and others), since tests read those through no call the index sees.

When the index root is a directory inside a larger repository (a service with its own .tessera/), these files count above it too: the monorepo’s root lockfile or a build file in a directory the root is inside, shown relative to the root (../../pnpm-lock.yaml), and CI configuration at the top. A sibling service’s files do not. A renamed file counts as deleted at its old path, so renaming a module selects the tests of its importers.

It also runs when a commit in the range has a Tessera-No-Skip: trailer, or when the change reaches more symbols than Tessera narrows (the selection would be most of the suite anyway). Trailers are read from the history the clone has: in a shallow clone (actions/checkout fetches one commit by default) Tessera says so in a note, and fetch-depth: 0 makes the trailer count. When the index holds no test files, the output says so and selects nothing: run everything then.

--format prints a runner’s command line instead of the list:

Format Prints
list (default) One test path per line
json Every test with its kind, reason, package and the chain from the test to the changed code
jest, vitest jest --runTestsByPath …, vitest run …
pytest pytest file::Class::test …, narrowed to the test functions that reach the change
go-test go test <packages> -run '^(TestA|TestB)$' per module
cargo cargo test -p <package> --test <target>

--package <name> restricts the selection to one workspace package (npm, pnpm or yarn workspaces, Cargo, Go or Maven modules). The summary goes to stderr, so stdout is only the list or the command. The exit code is 0 whatever is selected; 2 for a usage error (no git repository, an unknown revision).

The GitHub Action exposes the selection as outputs (tests, tests-file, full-run). In another CI:

Terminal window
# Run everything when Tessera says so, knows no tests, or fails.
if ! tessera affected "$BASE" > tests.txt 2> affected.log ||
grep -q -e '^full run: ' -e '^0 of 0 test files' affected.log; then
npx jest
elif [ -s tests.txt ]; then
xargs npx jest --runTestsByPath < tests.txt
fi

Over MCP the same selection is the affected tool.

Building the index is the slow part of a CI run; checking a change against it is fast. On microsoft/vscode (15,317 files), a cold index takes 44 s and a cached run, 20 commits on, 23.5 s; on repositories whose cold index takes a few seconds the cache saves little. tessera cache export and tessera cache import move a built index between machines as one file, so a run starts from the index of the commit it branched from and re-indexes only what changed. Tessera only reads and writes the file: storing it (actions/cache, GitLab’s cache:, Azure’s Cache@2, a bucket) is the CI’s job.

Terminal window
tessera cache export "$CACHE_DIR/tessera.cache" # after a run
tessera cache import "$CACHE_DIR/tessera.cache" # before the next one

Export brings the index up to date, then writes a consistent snapshot of it (taken while other processes may still be writing), compressed with zstd. The file appears whole or not at all: it is written beside the target and renamed into place. Its first two lines say what it holds:

tessera-cache
{"format":1,"version":"1.2.0","schema":9,"fingerprint":"…","excludes":[],"tree":"4b825dc…","root":".","files":1832,"size":41254912,"sha256":"…"}

tree is git rev-parse HEAD^{tree} where it was written (null outside git); root is where the indexed directory sits in its repository (. for the top, packages/api for one package of a monorepo).

Import refuses a file whole, before the index is touched, when it is:

Refused when Why
missing, not a cache, or damaged (checksum) nothing to trust
in another cache format, or from another Tessera version or index schema another version extracts and resolves differently
of another directory of the repository (root differs) its paths mean other files
built under another .tessera/config.toml it indexed other files
built with other --exclude globs than the index already here the local choice wins; match it or --reset-excludes
of another repository: fewer than half its files exist here a branch moves some files, never most

A refusal exits 3 with the reason on stderr; with --json it also prints {"imported": false, "reason": "…"}. Treat it as a cache miss: the next command builds the index from scratch.

Otherwise the cache replaces the index in one SQLite transaction, under the same lock the indexer takes, so a reader sees the old index or the new one. Then every file’s content is compared with the cache’s, and only files that differ, are new, or are gone are re-indexed:

Terminal window
$ tessera --json cache import tessera.cache
{ "imported": true, "file": "tessera.cache", "tree": "4b825dc…", "version": "1.2.0",
"schema": 9, "files": 1832, "reindexed": 14, "unchanged": 1818, "removed": 0 }

Keys. Key the stored file on the merge base’s tree, the Tessera version and the OS (line endings differ), and restore by the prefix without the tree, so a branch takes the newest cache when its exact merge base has none:

Terminal window
tree=$(git rev-parse "$(git merge-base HEAD "$BASE")^{tree}")
key="tessera-$(uname -s)-$(tessera --version | cut -d' ' -f2)-$tree"

Any cache is safe to import: a stale one costs re-indexing, and one from another version or configuration is refused. A cache only saves time when it was built from something close to the change, which is why the key starts from the merge base. Saving from the default branch’s pipeline gives every new branch a warm start.

GitLab CI, where cache keys are fixed before the job runs:

tessera:
variables:
TESSERA_VERSION: "1.2.0"
GIT_DEPTH: 0
cache:
key: tessera-$TESSERA_VERSION
paths: [.tessera-cache/]
script:
# A missing or refused cache only means indexing from scratch.
- tessera cache import .tessera-cache/index || true
- tessera verify --diff "$CI_MERGE_REQUEST_DIFF_BASE_SHA" --format gitlab > gl-code-quality.json || code=$?
- tessera cache export .tessera-cache/index
- exit ${code:-0}

tessera verify --changed, --staged or --diff <rev> takes --format:

Format For Shape
human a terminal (default) text
agent hooks and agents one line per blocking problem
json scripts the --json object
markdown a pull-request comment what the Action posts
sarif GitHub code scanning, any SARIF viewer SARIF 2.1.0
gitlab GitLab merge request widget Code Quality JSON array
azure Azure DevOps pipelines ##vso[task.logissue] logging commands
bitbucket Bitbucket Code Insights report and annotations JSON
ndjson agent loops reading a stream one finding per line, then a summary
junit Jenkins, CircleCI, Buildkite test reports JUnit XML with completed checks, including passes

Every format exits as verify does: 1 when a problem blocks, 0 otherwise, 3 when the index cannot be read. Each finding carries a fingerprint (16 hex digits) that stays the same when edits only move lines; the review formats use it so a tool tracks one issue across pushes. When one problem occurs at several lines (the same missing name called twice), the second and later occurrences get -2, -3 … in the GitLab fingerprint, the Bitbucket external_id and the SARIF partial fingerprint, so each stays its own issue.

V6 (a test that reaches the change) is a test to run, not a problem: the GitLab, Azure and Bitbucket formats leave it out; SARIF lists it as a note, NDJSON as a finding.

Terminal window
tessera verify --diff origin/main --format junit > tessera-junit.xml

JUnit requires --changed, --staged or --diff; symbol and snippet-only requests exit 2 before reading the snippet or building an index. As with other formats, global --json takes precedence. Fetch the comparison revision before running the command; the examples below assume Tessera is installed and origin/main exists locally.

One <testsuite name="tessera"> contains one <testcase> for each check ID and file actually evaluated (classname="V2", name="src/client.ts"). Files can include unchanged callers affected by the change. Successful cases come from completed, eligible name, arity, import or contract comparisons. Unknown, external, ignored and unindexed code does not manufacture passing cases. V5 is omitted without clients, or without historical providers and matching repository clients to evaluate. V6 is left out. A change with no eligible evaluations, including an empty index, has zero tests and an explanation in suite <system-out>.

A testcase has one <failure> containing every blocking finding’s message and file:line; advisory and actively waived findings appear in <system-out>. An expired waiver blocks again. failures counts failed cases, not findings. Partial evaluations with parse errors are marked <skipped> unless a blocking finding already fails the case. Missing or unparseable historical source is explained, never assumed to pass. For partially parsed historical providers, V5 attributes incompleteness to clients matching recovered provider facts, including surviving providers. Those facts do not create passing cases; a separate, complete evaluation of the same client creates a skipped aggregate. Other clients can retain their passes. Facts hidden entirely by a partial parse cannot be attributed. If no provider facts can be recovered, or existing historical source cannot be retrieved or parsed at all, affected clients are unknown: V5 conservatively marks every otherwise completed client evaluation incomplete. A successful Git tree lookup distinguishes genuinely new files, which do not impair historical coverage. Failed tree lookups leave existence unknown. The current contract index containing parse errors also makes completed client evaluations incomplete. None of these gaps creates a testcase on its own. Exit status remains 1 for blocking findings and 0 otherwise; a skipped check does not change that policy.

Paths, messages and attributes are XML 1.0 safe. Tabs, CR and LF in attributes use character references, preserving whitespace on XML parsing. Forbidden characters in messages are displayed as [U+XXXX]. In testcase names they use \u{XXXX} and literal backslashes are doubled, so a control character cannot alias a filename containing escape text. No output progress text is mixed into the XML.

Finding fingerprints are preserved verbatim in suite-level <properties>, which the pinned Jenkins xUnit junit-10.xsd accepts:

<property name="tessera.finding|V1|7372632F612E7473|1|fingerprint" value="0123456789abcdef"/>
<property name="tessera.finding|V1|7372632F612E7473|1|line" value="12"/>

The key is tessera.finding|CHECK|PATH_HEX|OCCURRENCE|FIELD. PATH_HEX is uppercase hexadecimal of the original path’s UTF-8 bytes (7372632F612E7473 means src/a.ts); OCCURRENCE starts at 1 within that check/file in finding order. FIELD is fingerprint or line. This binds each property to its testcase and finding even when fingerprints repeat. Cases sort by check and original path. See schema provenance. Schema validation does not prove acceptance by every CI version; importers may discard custom properties or identify tests by classname/name instead of the fingerprint. These recipes describe ingestion, not a live CI test.

Jenkins (JUnit step): archive results even when verify fails, then restore verify’s failure. allowEmptyResults permits a legitimate zero-execution report.

script {
def code = sh(returnStatus: true, script:
'tessera verify --diff origin/main --format junit > tessera-junit.xml')
try {
junit testResults: 'tessera-junit.xml', allowEmptyResults: true,
keepProperties: true, skipMarkingBuildUnstable: true
} finally {
if (code != 0) { sh "exit ${code}" }
}
}

CircleCI (store_test_results): the storage step runs after a failed command; verify’s exit is preserved.

steps:
- run:
name: Verify edit safety
command: |
mkdir -p test-results
tessera verify --diff origin/main --format junit > test-results/tessera.xml
- store_test_results:
path: test-results

Buildkite (JUnit import): configure a Test Engine suite token as BUILDKITE_ANALYTICS_TOKEN using your existing secret-management process. Artifact storage alone does not import test results. In a command step, upload to the test ingestion API before returning verify’s original failure. If verify succeeds, an upload failure fails the step instead. The importer limits a file to 5,000 test results; larger reports need splitting before upload.

Terminal window
code=0
tessera verify --diff origin/main --format junit > tessera-junit.xml || code=$?
upload=0
curl --fail-with-body \
-H "Authorization: Token token=\"${BUILDKITE_ANALYTICS_TOKEN:?Set the Test Engine suite token}\"" \
-F 'data=@tessera-junit.xml' -F 'format=junit' \
-F "run_env[CI]=buildkite" -F "run_env[key]=$BUILDKITE_BUILD_ID" \
-F "run_env[url]=$BUILDKITE_BUILD_URL" -F "run_env[branch]=$BUILDKITE_BRANCH" \
-F "run_env[commit_sha]=$BUILDKITE_COMMIT" -F "run_env[number]=$BUILDKITE_BUILD_NUMBER" \
-F "run_env[job_id]=$BUILDKITE_JOB_ID" \
https://analytics-api.buildkite.com/v1/uploads || upload=$?
if [ "$code" -ne 0 ]; then exit "$code"; fi
exit "$upload"

SARIF. Results carry partialFingerprints: {"tesseraFingerprint/v1": …}, and artifact URIs are percent-encoded relative paths (src/my%20dir/a.ts). With the Action, set upload-sarif: true; elsewhere, upload the file with your code scanning tool.

GitLab. Each issue has description, check_name (V1…V5), fingerprint, severity (major when it blocks, minor when it is advisory) and location.path / location.lines.begin:

tessera:
script:
- tessera verify --diff "$CI_MERGE_REQUEST_DIFF_BASE_SHA" --format gitlab > gl-code-quality.json
artifacts:
when: always
reports:
codequality: gl-code-quality.json
rules:
- if: $CI_PIPELINE_SOURCE == "merge_request_event"

Azure DevOps. One logging command per problem, type=error when it blocks and warning when it is advisory, with sourcepath, linenumber and code. Property values escape % (as %AZP25), line breaks, ; and ]; the message after the first ] is never split, so it escapes only % and line breaks:

- script: tessera verify --diff "$(BASE)" --format azure
displayName: Tessera

Bitbucket. {"report": …, "annotations": […]}: the report (result PASSED or FAILED, with counts) and one annotation per problem (external_id, annotation_type: BUG, severity HIGH or LOW, summary, path, line). Send each half to its API call; Pipelines authenticates through its local proxy:

- step:
script:
- git fetch origin "$BITBUCKET_PR_DESTINATION_BRANCH"
- |
tessera verify --diff "origin/$BITBUCKET_PR_DESTINATION_BRANCH" --format bitbucket > tessera.json && code=0 || code=$?
api="http://api.bitbucket.org/2.0/repositories/$BITBUCKET_REPO_FULL_NAME/commit/$BITBUCKET_COMMIT/reports/tessera"
jq .report tessera.json | curl -sf --proxy http://localhost:29418 -X PUT "$api" -H 'Content-Type: application/json' -d @-
jq .annotations tessera.json | curl -sf --proxy http://localhost:29418 -X POST "$api/annotations" -H 'Content-Type: application/json' -d @-
exit $code

Bitbucket takes at most 100 annotations per request; split larger lists with jq '.annotations[0:100]' and so on.

NDJSON. Each line is a finding exactly as in --json, and the last line is {"summary": {"ok", "findings", "blocking", "tests"}}, so a loop can act on each finding as it reads it:

Terminal window
tessera verify --changed --format ndjson | jq -c 'select(.blocking == true)'

On a repository that already has problems, turning on a check that fails the build on every old one is how checks get turned off. Record what is there today, and only new problems block:

Terminal window
tessera baseline # writes .tessera/baseline.json; commit it
tessera verify --diff origin/main

A finding in the baseline is still reported, marked (in the baseline), with waived: {"by": "baseline"} in JSON, and it does not block: not in verify, not in the Action’s fail-on: blocking, not in the edit hooks. tessera baseline --prune drops entries that no longer fire, and verify --no-baseline shows everything.

An intended break, such as an endpoint removed on purpose while its last client is migrated, gets a reviewed record instead of a disabled check:

Terminal window
tessera accept 3f9a0c1e8b2d4471 --reason "orders v1 retired; web moves in #412" --expires 2026-12-31 --by sam

Fingerprints come from verify --json (every finding has one; it stays the same when lines move). The entry goes to .tessera/accepted.toml, which is committed and reviewed like code. Until it expires, the finding is reported with its reason (in the terminal and in the pull-request comment) and does not block; after the expires day (UTC) it blocks again. An expires that is not a real date, quoted or bare (2026-12-31), lapses at once, with a warning naming the entry.

Both files are read leniently in one direction only: a file that cannot be read is named on stderr and waives nothing, so a broken file never turns checks off.