Skip to content

feat(api): use the Trunk API v2 when Trunk chooses it (TRUNK-21083) - #1256

Merged
trunk-io[bot] merged 6 commits into
mainfrom
tylerbeebe/trunk-21083-v2-client
Oct 28, 2026
Merged

trunk-io[bot] merged 6 commits into
mainfrom
tylerbeebe/trunk-21083-v2-client

Conversation

@acatxnamedvirtue

@acatxnamedvirtue acatxnamedvirtue commented Oct 26, 2026 •

Copy link
Copy Markdown
Contributor

What

The CLI and the test-runner plugins move from the v1 upload and quarantine routes to v2, behind a server-side choice, so the rollout can be ramped and reversed without a release.

Choosing the API, once per run, before anything else. ApiClient::get_upload_api_version calls POST /v1/metrics/getUploadApiVersion (trunk-io/trunk#34794) with the CLI version, org slug, collection id, repository, CI provider, whether the run is a fork PR, and caller (cli or test-runner-plugin).

  • Only an explicit "v2" means v2.
  • A timeout (2 s), a connection error, any non-200, or a body it cannot parse means v1, logged at debug level. A timeout, a connection error or a 5xx is retried once, so a slow answer costs at most 4 s.
  • Without an API token (forks, public repo ids) or without a collection id, the client uses v1 without calling, since v2 needs both.
  • One info line names the API: Using Trunk API v1 / v2.

On v2:

  • Auth: the same token, sent as Authorization: Bearer.
  • Quarantine lookup: POST /v2/test-collections/{id}/quarantine-status, batched into requests of at most 2,000 identifiers, sending each test's id only. Its testCollectionUuid and repositoryId turn the failure links into GUID links. A lookup of no tests makes no request.
  • Test-runner plugins: they page GET …/all-quarantined-tests?repoHost=…&repoOwner=…&repoName=…&limit=25000, read testCollectionUuid, repositoryId and quarantineEnabled from the page, and match each test by its computed GUID (gen_test_case_guid). No quarantine-status call. The disk cache keeps v1 and v2 entries in separate files.
  • Upload: POST /v2/test-collections/{id}/uploads. The bundle's bundle_upload_id_v2 (base64 of {id, createdAt, repoId}) and its test-collection fields are built from the response, which is what ingestion reads. The response's testCollectionUuid gives the upload GUID links even when the run made no lookup.
  • Types: the CLI's lookup returns QuarantineLookup and a runner's whole set QuarantinedTestSet (api/src/quarantine.rs). Each API's wire types convert into them, so v1's GetQuarantineConfigResponse carries no v2 fields.
  • No fallback: a failed v2 call is reported as a failure and never retried on v1, so an outage shows up as v2 errors and a retry cannot ingest a bundle twice.

Depends on trunk-io/trunk2#7611 (the list is a POST naming the repository in its body). Merge and deploy it first. It follows trunk-io/trunk2#7589 (merged), where the list returns its scope and the upload returns testCollectionUuid.

run_upload takes the choice through RunUploadOptions::upload_api_version, so a plugin's quarantine lookups and its upload use the same API.

Formatting only: the render_group block in cli/src/upload_command.rs (about 90 lines) is reformatted, not changed. main is not formatted under the pinned rustfmt, and the pre-commit trunk fmt formats a modified file whole.

Testing

  • api: the decision (request body, explicit v2, every v1 case, a slow answer, no call without a token or collection), the v2 upload mapping and bearer header, batching by 2,000, paging the quarantined set, no paging when quarantining is off, and no v1 call after a failed v2 lookup.
  • cli/tests/upload.rs: an upload that runs end to end on v2, checking the bundle's metadata and that no v1 route is called.
  • test_report/tests/quarantine.rs: a v2 run quarantines a test by its GUID.
  • The existing suites pass except three Swift xunit upload tests, which fail the same way on main in an environment without a Swift language server.

Linear: TRUNK-21083

🤖 Generated with Claude Code

@trunk-io

trunk-io Bot commented Oct 26, 2026 •

Copy link
Copy Markdown

😎 Merged successfully - details.

@codecov-commenter

codecov-commenter commented Oct 26, 2026 •

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 91.29288% with 66 lines in your changes missing coverage. Please review.
✅ Project coverage is 84.64%. Comparing base (9b17c69) to head (0397c90).

Files with missing lines Patch % Lines
cli/src/upload_command.rs 79.61% 32 Missing ⚠️
api/src/client/v2.rs 89.47% 22 Missing ⚠️
api/src/client/v1.rs 90.90% 6 Missing ⚠️
api/src/client/mod.rs 97.24% 3 Missing ⚠️
test_report/src/report.rs 97.74% 3 Missing ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##             main    #1256      +/-   ##
==========================================
+ Coverage   84.00%   84.64%   +0.63%     
==========================================
  Files          74       78       +4     
  Lines       18019    18633     +614     
==========================================
+ Hits        15136    15771     +635     
+ Misses       2883     2862      -21     

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@trunk-staging-io

trunk-staging-io Bot commented Oct 26, 2026 •

Copy link
Copy Markdown

Static Badge   Static Badge   Static Badge

Failed Test Failure Summary Logs
pending_quarantine_test should be quarantined when run with variant A test marked as pending was expected to fail but unexpectedly passed. Logs ↗︎
variant_quarantine_test should be quarantined when run with variant The test failed because it expected 2 + 2 to equal 5, but the result was 4. Logs ↗︎

View Full Report ↗︎ ⋅ Docs

@trunk-io

trunk-io Bot commented Oct 26, 2026 •

Copy link
Copy Markdown

Static Badge   Static Badge   Static Badge

View Full Report ↗︎ ⋅ Docs

@acatxnamedvirtue

Copy link
Copy Markdown
Contributor Author

@claude review

@acatxnamedvirtue

Copy link
Copy Markdown
Contributor Author

Independent review

Nothing here blocks merging. The routing logic is solid: v1 is the default, the version check fails open, v2 never falls back to v1, and the disk cache keeps v1 and v2 entries in separate files. The new api v2 tests pass (9/9) and cargo fmt --check is clean. I did not run the full CLI and test_report suites. Two things need attention in the test-runner plugin path, plus some smaller ones.

Worth fixing

1. Every plugin process now asks Trunk which API to use before reading the quarantine disk cache (test_report/src/report.rs, is_quarantined)

  • is_quarantined calls self.upload_api_version(...) before populate_quarantined_tests, so the version request always runs first. The answer is kept only in memory (self.0.upload_api_version), so each new process asks again.
  • Before this PR, a cache hit made no network calls. Now, for runners that start a fresh process per worker or per file, every process pays one more request of up to 2s, even when the quarantine list is cached.
  • It also weakens a claim in the description: "a plugin's quarantine lookups and its upload use the same API" only holds inside one process. If lookups run in workers and the upload runs in the parent, each asks separately, and during a ramp change they can get different answers.
  • Fix: save the chosen version in the cache, either as a small per-run entry or inside the cached config with the same TTL, and read it before calling getUploadApiVersion.

2. is_fork_pr reports a setting, not whether the run is a fork (cli/src/upload_command.rs:828)

  • It is computed as allow_forked_pr_uploads || public_repo_id.is_some(), but the version request only goes out when there is an API token. Real fork runs have no token, so they never send it.
  • So the field is never true for an actual fork. It is true only for same-repo runs whose CI config always passes --allow-forked-pr-uploads (or also sets a public repo id).
  • If the server ever uses isForkPr to keep forks on v1, those normal runs would stay on v1 without anyone noticing. Either detect forks from CI environment variables or drop the field.

Minor

  1. GUID links on v2 depend on the quarantine lookup having run. test_collection_id reaches the upload only through the test_collection_uuid mutex, which only quarantine-status fills. When the lookup is skipped (--disable-quarantining, no JUnit failures, or the plugin's upload in a new ApiClient with no failures), the end output falls back to short links. This is acceptable, but the hidden ordering between those two calls deserves a comment. A cleaner fix is for v2 uploads to return the collection UUID.
  2. The all-quarantined-tests paging loop never stops if the cursor doesn't change. A server bug that returns the same nextCursor would hang the customer's CI step. Stopping when next == cursor, or capping the page count, would prevent that.
  3. The render_group change in upload_command.rs is about 90 lines of formatting only. main isn't formatted under the pinned nightly rustfmt. Splitting it into its own commit, or noting it in the description, would make the diff easier to read.
  4. Dry runs (--dry-run) now make a network call to getUploadApiVersion. This is harmless, but they could skip it and use v1.

🤖 Generated with Claude Code

@acatxnamedvirtue

Copy link
Copy Markdown
Contributor Author

Thanks. Addressed in 82eab55:

  1. The plugin's choice is shared across processes. test_report keeps the chosen API on disk next to the quarantine cache, with the same TTL (TRUNK_QUARANTINED_TESTS_DISK_CACHE_TTL_SECS, 5 min by default), keyed by org, repository and collection. A process reads it before asking. publish reads it too, or asks once and passes the answer to run_upload, so worker processes and the uploading process agree. A run with a warm cache makes no extra call. New test: two reports that share only the disk make one getUploadApiVersion call.
  2. isForkPr now reports the run. On GitHub Actions it reads the event file and compares the pull request's head and base repositories. On CircleCI it reads CIRCLE_PR_NUMBER, which CircleCI sets only for forks. --allow-forked-pr-uploads and --public-repo-id no longer set it. A token-carrying fork run, such as pull_request_target, is the case it now catches. New unit tests cover both CI systems.
  3. GUID links depend on the lookup. The field comment now says the upload takes the collection UUID from an earlier quarantine-status call, and that a run without one gets short links. Returning the UUID from v2 uploads would remove the dependency; that is a public-api change for a follow-up.
  4. The paging loop stops on a repeated cursor with an error, which the plugin reports as a failed lookup. New test.
  5. The render_group change is formatting only. The PR description now says so. It can't be dropped, because the pre-commit trunk fmt formats a modified file whole.
  6. A dry run uses v1 without calling.

@acatxnamedvirtue

Copy link
Copy Markdown
Contributor Author

One more change, and it needs a deploy order. The v2 quarantine lookup now sends only each test's id, since v2 matches an id by itself. That cuts a 2,000-failure request from about 389 KB to about 80 KB.

Order: trunk-io/trunk2#7531 must be live in production before any run uses v2 with this build. Until #7531 deploys, v2 still requires name and parentName on every identifier, so an id-only body gets a 400. In practice the flag is at 0%, so no run takes the v2 path before that. But don't send a production org to v2 on this build until #7531 is promoted.

@TylerJang27 TylerJang27 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A few spec contention points at the beginning. Almost there

Comment thread api/src/client.rs Resolved
Comment thread api/src/client.rs Resolved
Comment thread api/src/client.rs Resolved
Comment thread api/src/client.rs Resolved
Comment thread api/src/client.rs Resolved
Comment thread api/src/message.rs Resolved
Comment thread cli/src/upload_command.rs Resolved
Comment thread api/src/client.rs Resolved
Comment thread api/src/client.rs Resolved
Comment thread api/src/client.rs Resolved
Comment thread api/src/quarantine.rs Resolved
Comment thread cli/tests/upload.rs Resolved
acatxnamedvirtue and others added 6 commits October 28, 2026 15:13
Before a run looks up quarantine or uploads, ask
POST /v1/metrics/getUploadApiVersion which API to use (2 s timeout, no
retry; anything but an explicit "v2" is v1). On v2 the CLI and the
test-runner plugins call quarantine-status (batched by 2,000),
all-quarantined-tests (limit=25000, matched by computed test GUID) and
uploads, with the token as a bearer credential. A failed v2 call is
reported, never retried on v1.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…eal forks

- test_report keeps the chosen API on disk with the quarantine cache's TTL, and
  publish reads it, so worker processes and the uploading process agree and a
  cached run makes no extra call.
- isForkPr comes from the GitHub event (head and base repositories differ) or
  CircleCI's CIRCLE_PR_NUMBER, not from --allow-forked-pr-uploads.
- A dry run uses v1 without asking.
- Paging all-quarantined-tests stops with an error on a repeated cursor.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
v2 matches an identifier with an id by the id alone, so the names only added
bytes: about 389 KB for 2,000 failures becomes about 80 KB. Needs
trunk-io/trunk2#7531, which stops requiring name and parentName beside an id.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
From review (Tyler Jang):
- A test runner pages all-quarantined-tests by repository coordinates and
  reads the scope from the page, with no quarantine-status call first.
- The v2 upload reads the collection UUID from its own response, so the
  client no longer caches it from an earlier lookup, and a run with no
  lookup still gets GUID links.
- The CLI's lookup and a runner's whole set return their own types
  (QuarantineLookup, QuarantinedTestSet), so the v1 wire type keeps no
  skipped v2 fields. A lookup of no tests makes no request.
- The v2 upload body borrows the request's fields instead of cloning them.
- The API version check retries once after a timeout, connection error
  or 5xx, so a slow answer costs at most 4s.

Needs trunk-io/trunk2#7589 deployed first.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…tory as null

all-quarantined-tests is now a POST whose body names the repository as
quarantine-status's does; the cursor and limit stay in the query. v2
refuses empty repository parts, so the upload, the lookup and the list
all send null for an upload without a repository.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…to api/tests

client.rs becomes client/{mod,v1,v2}.rs: mod.rs keeps the client, the
version check and the calls that choose an API; v1.rs and v2.rs hold each
version's requests. The v2 client tests move to api/tests/v2_client.rs on
MockServerBuilder, whose state now keeps the requests to the routes a test
adds. Also rewords QuarantinedTests::ByTestCaseGuid's doc comment.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@acatxnamedvirtue
acatxnamedvirtue force-pushed the tylerbeebe/trunk-21083-v2-client branch from c9c7ecc to 0397c90 Compare October 28, 2026 15:18
@trunk-io
trunk-io Bot merged commit 2da0f15 into main Oct 28, 2026
35 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Development

Successfully merging this pull request may close these issues.

3 participants