Inputs move onto the task, so every task is keyed whether it caches or not. A gitignored file or a symlink you name is an input. A test can read what its build wrote, vx upgrade moves your plugins with it, and a run waits on less.
Every task now has a key, cached or not, so a step that is never cached
can no longer leave a stale build behind it. vx also sees inputs it used
to miss: a gitignored .env.local you name, and the file behind a
symlink. A test may read what its own build wrote, vx upgrade moves
your plugins with vx, and a run waits only on work that holds it up.
Read the breaking changes before you update: every
config that sets cache.inputs needs a one-line move.
A task’s inputs lived under cache, so a task that was never cached had
no way to say what it read. Say a gen step writes API_URL into a file
that a cached build reads. Changing API_URL did not change build’s
key, and the next run restored the old build.
inputs now sits on the task, beside exec. Every task is keyed on
its inputs, and the tasks behind it fold that key, whether either one
caches. cache keeps only outputs and inputStability.
A task that runs a command but declares no inputs has no key, so the
tasks behind it miss rather than hit stale. inputs: { files: [] }
keys a task on its config alone. bunx @vzn/vx-migrate, turbo() and
nx() key uncached Turbo tasks and Nx targets on their own inputs too.
An old config is refused at the line to fix:
Vite and Next read .env.local on their own, and generated code often
lands in an ignored folder. vx keys only what git lists, so there was no
way to key those files. A symlinked input was keyed by the path it
points to, not by what is there, so an edit behind the link replayed the
old output.
Now naming an ignored file is enough. A path git ignores, or a glob
whose fixed part git ignores (dist/**), is read from disk and hashed.
Its bytes move the key, but vx never caches the file itself. A broad
glob such as src/** still skips ignored files, so node_modules is
never walked. A symlinked input is keyed on its target’s content,
wherever the target lives, and vx watch re-runs when it changes.
inputs: { files: ['src/**', '.env.local'] }
Terminal window
$echo'API=two'>web/.env.local
$vxrunbuild--filterweb
⏺︎6mssuccessmissweb#build
$echov2>shared/theme.css# web/src/theme.css links here
$vxrunbuild--filterweb
⏺︎7mssuccessmissweb#build
Machines with different ignored files compute different keys, so they
do not share those entries. --affected follows only what git sees, as
in Nx: an ignored edit alone selects nothing, but any run of the task
misses. See Keys from git.
vx lock refused any task whose inputs matched another task’s outputs.
That kept every key known before a run, but it also refused a test
that reads its own build’s dist/.
A task may now read the outputs of a task it depends on, as long as its
key does not fold that task: set inputs.tasks and leave the producer
out. Its key waits for the producer and hashes the bytes it wrote. When
a build reruns and writes the same bytes, the test still hits.
test: {
dependsOn: ['build'],
exec: { command: 'grep -q v dist/out.txt' },
inputs: { files: ['dist/**'], tasks: [] },
cache: { outputs: { files: [] } },
},
Every other read of another task’s outputs is refused, and the error
names both fixes:
Terminal window
$vxlock
✗vxlocklocknotwritten
web#testreads"dist/**"ininputs.files,whichmatchesweb#build's output "dist/**" — a task'skeymustnotreadtheoutputsofataskitdoesnotdependon,orwhosekeyitalreadyfolds
Excludeit:add"!dist/**"toweb#test's inputs.files, or depend on web#build and leave it out of web#test'sinputs.tasks,soitskeyhashesthosebytesinstead
The check is always on now, and run.rules.upfrontKeys is gone.
vx upgrade moved only @vzn/vx. Plugins such as @vzn/vx-lockfile
stayed on the old release, and every @vzn package releases together,
so the mix could break on a missing export.
Now one install moves vx and every @vzn/vx-* plugin your
package.json lists, pinned to the same release, with your package
manager:
A plugin left behind is installed even when vx is already current. If
pnpm’s minimumReleaseAge refuses a release that is too new, the error
names the packages to list under minimumReleaseAgeExclude.
Last release showed vx’s own steps as rows beside your tasks. Some of
them did not hold the run up, but they still read as a wait, and two
did wait without needing to.
A step shows only when the run waited on it, before the first task or
at the end. A step that ran beside the tasks, like vx:prefetch,
never shows; vx last lists it under the tasks.
With a remote cache, each task’s log, and a failed task’s outputs,
upload the moment the task ends, not in a step after the last task.
cache.upload.failures picks which runs keep failures: CI (the
default), 'local' or 'always'.
With @vzn/vx-schedule-history, a branch with no timings starts at
once in local order, and re-ranks the tasks still waiting when main’s
timings arrive from the remote.
vx init always installs the latest vx and moves every @vzn/vx-*
plugin with it. Re-run in a repo it had set up, it no longer crashes or
removes vx-migrate while .vx/workspace.ts still imports it.
On a file system whose clock runs behind vx’s (WSL2 /mnt/c, a Docker
Desktop bind mount, NFS), an edit made while a task ran could be saved
under the old key and replayed later. vx now measures the lag once per
run and widens its checks by it, so that result is not saved.
yarn() reads Yarn 1 workspaces from package.json, so ^build
follows them right after vx init.
npm(), bun() and Yarn berry’s yarn() warn at vx lock when the
lockfile does not list a workspace yet, and name the install to run.
bunx @vzn/vx-migrate stops when it loads an @vzn/vx from another
release, naming both versions and where that copy came from.
Migrated Nx tasks:
They list the root tsconfig.base.json as an input, as Nx hashes it.
{workspaceRoot}, {projectRoot} and {projectName} in a runtime
input are written as the paths and name they stand for.
A runtime input that exits non-zero keys its error text instead of
failing the task.
A target that reads files from a project vx-migrate could not
migrate is left uncached, and the report says why.
tasks.<name>.cache.inputs → tasks.<name>.inputs. A config still
spelling it under cache is refused.
cache on a task with no exec → refused. A keyed group is inputs
with no exec and no cache.
A task that folds an uncached task with no inputs → never hits. Give
that task inputs ({ files: [] } keys it on its config).
.vx/projects.lock.json → version 4: run vx lock.
run.rules.upfrontKeys and VX_RUN_RULES_UPFRONT_KEYS → removed. A
task reading another task’s outputs must depend on it and leave it
out of inputs.tasks, or exclude them with a ! entry.
A task with a symlinked input → misses once, as its key now holds the
target’s content.
Plugin API: CacheKeyInput.linkTargetsKeyed → removed;
PreparedRun.priorities takes an optional ScheduleRun; the
vx:keep-run step → gone; vx:schedule and vx:telemetry →
<plugin>:schedule and <plugin>:telemetry.
A cache hit now rewrites only the outputs that changed, in place, and writes nothing a task did not declare. A local hit restores while its dependencies still run. A generated client in node_modules can be an input, vx upgrade -g moves only the global vx, and only vx decides what reaches a remote cache.
A live run now shows what vx itself is doing and what each idle worker waits on, the footer counts remote failures and uploads in flight, --force no longer re-uploads what the cache holds, vx lock refuses fewer configs that were fine, and vx run sheds nine flags.
Every vx verb now speaks in one shape and says how to fix what it refuses, a CI run keeps the logs and outputs you need to debug it, a new branch forecasts from main's timings, and a run your local cache answers stays off the network.
Turborepo, Nx and other product names are trademarks of their owners. vx is not affiliated with or endorsed by them.