Skip to content
GitHubRSS

CI and remote

Run what a change touched, and reuse what another machine already built.

vx run build runs build in the package you are in, and what it depends on.

Terminal window
vx run build --all # every package that has build
vx run build --filter "@app/*" # packages whose name matches
vx run build --filter "...@app/ui" # a package and everything that depends on it
vx run test --affected # changed since the base branch, and dependents
vx run test --affected=origin/main # changed since that ref
vx run app#build api#test # exact tasks, from anywhere
vx run lint test build --all # several tasks, one graph
vx run test -- --bail # the child runs: bun test --bail; the key sees it
vx run build --all --dry # the plan, nothing runs
vx run build --graph=g.dot # the task graph as Graphviz DOT
vx watch test # re-run whenever its files change

--dry prints what would run and where each result would come from:

would run:
◉ @acme/api#build cache hit (local) 8625b603
◉ @acme/ui#build cache hit (local) 71e5d9a0
◉ @acme/web#build cache hit (local) 42e9b39d
3 task(s) planned, 3 cache hits (3 local).
FlagDoes
--no-cacheignore the cache: no reads, no writes
--forcerun everything, then refresh the cache
--concurrency <n>at most n tasks at once (default: the cores you may use)
--continuekeep going after a failure (modes)
--output-logs <m>full, errors-only, hash-only or none
--summarizewrite a JSON summary of the run
--frozenload the configs from vx-lock.json (below)

Every flag: the CLI reference.

Check out with fetch-depth: 0: --affected needs the history. Its base is the target branch on a pull request, the commit before the push on a push.

.github/workflows/ci.yml
on: [pull_request, push]
jobs:
ci:
runs-on: ubuntu-latest
permissions:
contents: read # checkout; naming any permission drops the rest
checks: write # the PR check; the job summary needs nothing
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- uses: oven-sh/setup-bun@v2 # the runner has no Bun
- run: npm install -g @vzn/vx
- run: bun install --frozen-lockfile
- run: >
vx run ci
--affected=${{ github.event_name == 'pull_request'
&& format('origin/{0}', github.base_ref)
|| github.event.before }}
env:
GITHUB_TOKEN: ${{ github.token }} # the PR check; without it, no check
vx.workspace.ts
import { defineWorkspace } from '@vzn/vx'
import { github } from '@vzn/vx-github'
export default defineWorkspace({ plugins: [github()] })

github() writes a job summary, failures first, and a PR check. The check needs GITHUB_TOKEN in the vx step’s env, as above (the runner sets GITHUB_REPOSITORY and GITHUB_SHA); without it the check is skipped and the summary still writes. Outside GitHub Actions it declines. A test renders this sample:

5 tasks · 2 executed · 2 cache hits (1 remote) · 1 failed · 21.4s

  • @acme/web#build — exit 2
TaskStatusDuration
@acme/web#build❌ failed3.1s
@acme/web#test✅ ran4.2s
@acme/api#build☁️ remote cache0ms
@acme/ui#build⚡ cache0ms
@acme/ui#lint⏭️ skipped0ms

vx 0.0.21 · vx run ci --all · 3/5 passed · 2 restored

A failure on inputs that passed before is named under the run’s footer:

Flaky: 1 task with the same inputs both passing and failing on record
✗ web#test — failed on inputs that passed 3× before

vx lock writes each project’s resolved config, with a hash of its config file, to vx-lock.json; commit it. vx run ci --frozen loads the configs from the lock instead of evaluating them, and builds the graph from them as usual. A frozen run trusts the lock and checks nothing: vx lock --check fails on drift, so run it in CI too. It buys determinism, not speed: on the 1,000-project bench a plain run’s median of 177 ms against frozen’s 165 is a tie.

  • --affected has no base here … a shallow clone? Set fetch-depth: 0.
  • nothing affected since <ref> on every push. origin/main on a push to main is the commit itself. Use github.event.before.
  • The whole log lands in the job summary. Use --report-file="$GITHUB_STEP_SUMMARY", not a >> redirect.

A shared cache is a plugin. vx looks here, then asks the remote, and uploads new results in the background.

vx.workspace.ts
import { defineWorkspace } from '@vzn/vx'
import { reapi } from '@vzn/vx-reapi'
export default defineWorkspace({
plugins: [reapi({ endpoint: 'cache.example.com:443' })], // or VX_REAPI_ENDPOINT
})

@vzn/vx-reapi speaks Bazel’s Remote Execution API (NativeLink, BuildBuddy, Buildbarn, bazel-remote). It re-hashes every blob it reads: a corrupt download, a down server or a refused token is a miss, never wrong bytes. With no endpoint, it declines.

turboCache() from @vzn/vx-migrate keeps a Turborepo cache, Vercel’s included, and reads Turbo’s own variables:

Terminal window
bun add -d @vzn/vx-migrate
npx turbo login && npx turbo link # stores a token and a team
export TURBO_TOKEN=… TURBO_TEAM=…

Then declare plugins: [turboCache()]. nxCache() does the same for a self-hosted Nx cache; your own backend is a plugin. A laptop that only reads runs with --cache=local:rw,remote:r.

reapi() can also send tasks to a worker pool (NativeLink, BuildBuddy, Buildfarm); the graph and the scheduling stay here. It is never on by default:

vx.workspace.ts
import { defineWorkspace } from '@vzn/vx'
import { reapi } from '@vzn/vx-reapi'
export default defineWorkspace({
plugins: [
reapi({
endpoint: 'grpcs://cache.example.com:443',
execute: true, // or VX_REAPI_EXECUTE=1
platform: { OSFamily: 'Linux', 'container-image': 'docker://node:22' },
capacity: 64, // remote tasks at once, apart from your cores
}),
],
})

--dry says where each task runs (@vx/reapi or @local). exec.remote: false, sandboxed and persistent tasks, and what depends on them, stay here. A task with no cache block stays here too, because the executor declines it, but what depends on it can still go remote. Workers have no node_modules: make the install a remote: 'only' task the others depend on. The image needs /bin/sh and your toolchain.

A worker gets only what cache.inputs declares, so a task that fails there and passes here reads a file it never declared.

  • A down server is a cache miss, never a hung run.
  • Uploads chunk at 65535 bytes (SAFE_CHUNK_BYTES), the size no Bun http2 stall has been seen at. A larger chunkBytes that stalls retries once at that size before the task fails.