If a Docker build or CI job that passed yesterday now dies on The wheel is invalid: Cannot install into symlinked directory, nothing is wrong with your wheel, image, or virtual environment. uv 0.12.14 (released 2026-09-15) started refusing to install a wheel whose destination tree contains a directory symlink, and uv 0.12.15 shipped the same day to revert it. Bump the version, then spend ten minutes finding the dependency that genuinely writes through a symlink, because the check it tripped is coming back.

The change that breaks you is not in the 0.12.14 changelog. Those notes cover resumable downloads, MAX_PATH support on Windows, and a new exit-code scheme; the word "symlink" does not appear. The behaviour arrived with pull request #21569, "Reject symlinked wheel installation destinations", merged 2026-09-10 and unmentioned in the release. An assistant answering from the changelog will tell you your environment is broken, which is the same failure mode that makes version-gated breakage in other toolchains so expensive to diagnose, catalogued for the JavaScript side in the pnpm 12 upgrade traps.

Prerequisites

  • uv 0.12.13, 0.12.14, or 0.12.15, and write access to wherever your pipeline decides the uv version. Check with uv --version.
  • A Linux host or container to reproduce on. The Docker surface needs Docker plus a Debian-based official image (python:3.11-slim-bookworm, python:3.13-bookworm, python:3.12-slim-trixie and siblings all carry the trigger). -alpine images do not.
  • Knowledge of where the version is actually pinned: a COPY --from=ghcr.io/astral-sh/uv:<tag> line, an astral-sh/setup-uv step, a Renovate-managed tool version, or a base image with uv baked in. An unpinned tool fetched at build time is the same exposure class covered in the gaps SHA-pinning GitHub Actions leaves open.

What I read while writing this: the 0.12.15 release notes, the 0.12.14 release notes, the Docker reproduction in astral-sh/uv#21692, the --target . report in astral-sh/uv#21694, and the reverted PR itself.

Step-by-step

1. Read the path after the colon, not the wheel name

The wheel named in the first line is a red herring. It is whichever wheel in the install set happened to carry a .data/ payload first. The diagnosis is the path:

error: Failed to install: clevercsv-0.8.4-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl (clevercsv==0.8.4)
  cause: The wheel is invalid: Cannot install into symlinked directory: /usr/local/man
  cause: The wheel is invalid: Cannot install into symlinked directory: .../packages/robocar/simulation/.venv/lib64

The first is a system install into a Debian python:* image, where /usr/local/man is a symlink to share/man. The second was reported on a GitHub Actions runner during uv sync --extra dev, where uv venv had created lib64 as a symlink to lib on 64-bit Linux. A third surface prints a completely different string, which is why people miss it.

error: The wheel is invalidWhich cause string?Path after the colon--target=. or --target=./relative root normalizes to an empty pathuv pip install --systeminto a Debian python:* imageinstall into a venv on 64-bit Linuxlib64 -> libUpgrade to uv 0.12.15Then audit the wheel thatactually crosses the symlinkCannot install into symlinked directoryWheel directory entry escapes its destination/usr/local/man.venv/lib64

2. Confirm the symlink exists so you stop suspecting the wheel

find /usr/local -maxdepth 1 -type l -printf '%p -> %l\n'
find .venv -maxdepth 1 -type l -printf '%p -> %l\n'

In python:3.11-slim-bookworm the first prints /usr/local/man -> share/man. In a uv-created environment on x86-64 the second prints .venv/lib64 -> lib. Both layouts are normal, both predate uv by years, and neither is something you introduced. Confirming this first stops the two hours people otherwise spend rebuilding wheels.

3. Reproduce it in three lines before changing anything

FROM python:3.11-slim-bookworm
COPY --from=ghcr.io/astral-sh/uv:0.12.14 /uv /bin/uv
RUN uv pip install --system clevercsv==0.8.4

docker build on that fails with the /usr/local/man cause. It is the reproduction filed as astral-sh/uv#21692 on 2026-09-15. clevercsv is incidental: it ships man pages in its .data/data/ tree, so substitute any wheel that does. Having the failure on demand is what lets you prove the bump worked rather than hoping.

4. Pin 0.12.15 where the version actually comes from

COPY --from=ghcr.io/astral-sh/uv:0.12.15 /uv /uvx /bin/
- uses: astral-sh/setup-uv@bec219d24cd3e171d82865faccec33120bb574f4 # v10.1.0
  with:
    version: "0.12.15"

The 0.12.15 notes are explicit: "This release fixes a regression in 0.12.14 that lead to rejecting valid installation commands such as using uv pip install --system in python:* docker images or when using uv pip install --target .", with the single bug-fix entry "Revert 'Reject symlinked wheel installation destinations'".

For a standalone-installer uv, uv self update is enough. One trap on the Actions side: setup-uv's version input searches your config files before falling back to the latest release, so if a committed uv version constraint is what resolved 0.12.14, editing the workflow alone will not move it. Change the constraint, not the step.

5. If you cannot bump today, patch the destination

Only worth doing for a hard-pinned 0.12.14 you cannot reach. For the Docker surface, replace the symlink with a real directory before installing:

RUN rm -f /usr/local/man && mkdir -p /usr/local/man

For the virtual-environment surface, pin the offending package away or commit the uv.lock that never selected it. Rewriting .venv/lib64 is riskier, because the interpreter's own layout expects that symlink to be there.

6. Find the wheel that really crosses the symlink

This is the step that survives the revert. Pull the wheel and list its data tree:

pip download --no-deps --dest /tmp/wheels clevercsv==0.8.4
unzip -l /tmp/wheels/clevercsv-*.whl | grep '\.data/'

Entries under .data/data/, .data/scripts/, or .data/platlib/ are the ones mapped onto scheme roots at install time, so they are the ones that can land inside a symlinked directory. Write down which of your dependencies have them. That list is your blast radius when the hardened check returns, and it is worth keeping next to whatever inventory you already maintain of packages that execute code at install time, the hazard that the arrayref attack showed for cargo build.rs.

Verify it works

uv --version
uv pip install --system --no-cache clevercsv==0.8.4

Expect uv 0.12.15 and a successful Installed 1 package line. Rebuild the step 3 Dockerfile with the 0.12.15 tag and it completes. Then check the --target surface separately:

uv pip install --python python3.14 --system --no-compile --no-cache --target=. annotated-types==0.8.0

On 0.12.14 that failed for every wheel with The wheel is invalid: Wheel directory entry escapes its destination: annotated_types-0.8.0.dist-info, because the relative installation root . normalizes to an empty path (astral-sh/uv#21694). Same pull request, different message, and no search engine currently connects the two strings. On 0.12.15 it installs.

Common pitfalls

Deleting and recreating the virtual environment does nothing. uv venv recreates lib64 -> lib, because that is the normal layout on 64-bit Linux rather than corruption. Advice that opens with "remove .venv" is aimed at the one thing you cannot remove.

Your exit codes changed in the same release. The 0.12.14 notes state that "Package-operation exit codes now reflect the underlying cause: expected failures return 1, while recognized operational and internal failures return 2." A wrapper script branching on $? -eq 1 to mean "resolution failed" will now take the wrong branch on an operational failure, silently. Grep your CI for literal exit-code comparisons around uv while you are already in there.

A cached Docker layer hides the fix. If the COPY --from=ghcr.io/astral-sh/uv:0.12.14 line sits above your dependency install and you only edited the tag, rebuild that stage with --no-cache. Layer ordering makes image debugging misleading in general, which I went into alongside the containerd store in docker save "no suitable export target found".

Alpine passing convinces people the bug is not real. python:*-alpine has no /usr/local/man symlink, so a matrix that only exercises Alpine stays green while every Debian job fails.

Treat the rejection as returning. PR #21569 was reverted for the relative-path and system-directory regressions, not because its reasoning was wrong: when an installation destination already contains a directory symlink, merging the wheel payload can write files outside the environment. Pinning 0.12.15 buys time, which is why step 6 is the actual work.

Wrap-up

You have the mapping from error path to surface, a three-line reproduction, a one-line bump to 0.12.15, per-surface workarounds for a pin you cannot move, and a list of dependencies whose .data/ trees make them candidates when the check comes back hardened.

The next thing worth doing is to stop discovering uv releases through red CI. Add a scheduled matrix row that runs your install against the newest uv with --no-cache on a Debian-based image. The window between the 0.12.14 regression and the 0.12.15 fix was hours, and the only teams who never noticed were the ones whose pinned version does not move on its own: https://github.com/astral-sh/uv/releases/tag/0.12.15