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.
Test selection: tessera affected
Section titled “Test selection: tessera affected”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/main1 of 40 test files selected (39 skipped) for changes since origin/maintest/repo.test.ts$ tessera affected origin/main --explain test/repo.test.tstest/repo.test.ts is selected (reached): describe("loadUser").it("returns the user") → loadUser → UserRepoA 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.lockand others); - a build file (
package.json,Cargo.toml,go.mod,pom.xml, any*.gradleor*.gradle.kts, Gradle version catalogs,pyproject.toml,requirements*.txtandrequirements/,.npmrc,.yarnrc.yml,Makefileand 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:
# 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 jestelif [ -s tests.txt ]; then xargs npx jest --runTestsByPath < tests.txtfiOver MCP the same selection is the affected tool.
Index cache
Section titled “Index cache”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.
tessera cache export "$CACHE_DIR/tessera.cache" # after a runtessera cache import "$CACHE_DIR/tessera.cache" # before the next oneExport 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:
$ 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:
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}Report formats
Section titled “Report formats”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.
JUnit XML
Section titled “JUnit XML”tessera verify --diff origin/main --format junit > tessera-junit.xmlJUnit 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-resultsBuildkite (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.
code=0tessera verify --diff origin/main --format junit > tessera-junit.xml || code=$?upload=0curl --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"; fiexit "$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: TesseraBitbucket. {"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 $codeBitbucket 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:
tessera verify --changed --format ndjson | jq -c 'select(.blocking == true)'Baseline and accepted breaks
Section titled “Baseline and accepted breaks”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:
tessera baseline # writes .tessera/baseline.json; commit ittessera verify --diff origin/mainA 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:
tessera accept 3f9a0c1e8b2d4471 --reason "orders v1 retired; web moves in #412" --expires 2026-12-31 --by samFingerprints 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.