Skip to content

fix(pytest-matrix): pin uv, because the per-runner cache fix cannot fire - #24

Merged
ywatanabe1989 merged 1 commit into
mainfrom
fix/pin-uv-to-stop-toolcache-miss-storm
Jul 30, 2026
Merged

fix(pytest-matrix): pin uv, because the per-runner cache fix cannot fire#24
ywatanabe1989 merged 1 commit into
mainfrom
fix/pin-uv-to-stop-toolcache-miss-storm

Conversation

@ywatanabe1989

Copy link
Copy Markdown
Contributor

fix(pytest-matrix): pin uv, because the per-runner cache fix cannot fire

The RUNNER_TOOL_CACHE override on the setup-uv step has never taken
effect, and cannot. Measured, not inferred.

WHAT THE OVERRIDE WAS FOR, and the diagnosis in its comment is correct:
~80 runners share one mutable tool cache, @actions/tool-cache's writer
rmRF's / before refilling it, and its reader treats
dir + .complete as a HIT without checking the binary is inside. So
concurrent jobs delete the tree each other is reading or filling, and
the reader then puts a binary-less directory on PATH.

WHY IT CANNOT FIRE. scitex-cards PR #667, run 30568910438: all three
matrix legs -- including the py3.12 leg that PASSED -- resolved
/home/ywatanabe/.runner-toolcache, not the per-runner path. A probe
(scitex-cards branch probe/toolcache-env, run 30571092482) then asked
the same question of a shell step and of the JS action with distinct
sentinel paths:

runner.name = spartan-pooled-cpu-01
RUNNER_TOOL_CACHE = /home/ywatanabe/.runner-toolcache
AGENT_TOOLSDIRECTORY = /home/ywatanabe/.runner-toolcache

shell step, env: RUNNER_TOOL_CACHE=/tmp/probe-shell/
observed inside the step: /home/ywatanabe/.runner-toolcache
JS action, env: RUNNER_TOOL_CACHE=/tmp/probe-jsaction/
setup-uv resolved: /home/ywatanabe/.runner-toolcache

The override fails even for a plain shell step, so this is not a
setup-uv bug and not a JS-action quirk: the runner re-injects
RUNNER_TOOL_CACHE from AGENT_TOOLSDIRECTORY when it builds each step's
environment, after merging the user's env:. No workflow-layer override
of that variable can win. The existing fix is configured, visible,
believed to work, and inert -- which is worse than absent, because its
presence stopped anyone looking further.

WHAT THIS COMMIT DOES. It does not try to fix the variable again. It
removes the TRIGGER instead. The destructive writer runs only on a cache
MISS; on a HIT the action calls find() + addPath and writes nothing.
Unpinned, version resolves to latest, so an upstream release turns
every concurrent job into a simultaneous miss -- exactly the storm the
original comment describes. Pinning makes every job a hit.

The shared cache is healthy right now (probe: which uv ->
/home/ywatanabe/.runner-toolcache/uv/0.12.0/x86_64/uv, uv 0.12.0), so
pinning to 0.12.0 pins to a good tree rather than freezing a broken one.

This is a mitigation and is labelled as one in the comment. The HAZARD
-- one mutable directory shared by every runner -- is only fixable where
the value is set, in the runner supervisor, by giving each runner its
own AGENT_TOOLSDIRECTORY. That is filed for scitex-hpc, who own the
runners. Both belong in place; neither replaces the other.

Independently justified: an unpinned toolchain silently changes what CI
runs whenever an upstream release lands, which is the opposite of a
reproducible build.

Bumping the pin re-opens the window for one run, so the comment says to
warm the new version on the pool first, or bump while it is idle.


Blast radius: every caller of this reusable workflow (~58 repos) gets a pinned uv instead of latest. They are all resolving to 0.12.0 today anyway, so the immediate behaviour is unchanged; what changes is that a future upstream release no longer lands on all of them simultaneously.

Reversible: delete the version: line.

Follow-up, filed separately for the runner owner: ci-agent-toolsdirectory-must-be-per-runner-20260731 — set AGENT_TOOLSDIRECTORY per runner in the supervisor. That is the only place the shared-directory hazard can actually be removed; this PR only removes the trigger.

Regression test: scitex-cards branch probe/toolcache-env (one job, installs nothing extra). After the supervisor change it should show AGENT_TOOLSDIRECTORY differing per runner.

🤖 Generated with Claude Code

https://claude.ai/code/session_01QQBFHZzKnEAa54hpZia3Vc

The RUNNER_TOOL_CACHE override on the setup-uv step has never taken
effect, and cannot. Measured, not inferred.

WHAT THE OVERRIDE WAS FOR, and the diagnosis in its comment is correct:
~80 runners share one mutable tool cache, @actions/tool-cache's writer
rmRF's <ver>/<arch> before refilling it, and its reader treats
dir + .complete as a HIT without checking the binary is inside. So
concurrent jobs delete the tree each other is reading or filling, and
the reader then puts a binary-less directory on PATH.

WHY IT CANNOT FIRE. scitex-cards PR #667, run 30568910438: all three
matrix legs -- including the py3.12 leg that PASSED -- resolved
/home/ywatanabe/.runner-toolcache, not the per-runner path. A probe
(scitex-cards branch probe/toolcache-env, run 30571092482) then asked
the same question of a shell step and of the JS action with distinct
sentinel paths:

  runner.name          = spartan-pooled-cpu-01
  RUNNER_TOOL_CACHE    = /home/ywatanabe/.runner-toolcache
  AGENT_TOOLSDIRECTORY = /home/ywatanabe/.runner-toolcache

  shell step, env: RUNNER_TOOL_CACHE=/tmp/probe-shell/<runner>
    observed inside the step: /home/ywatanabe/.runner-toolcache
  JS action, env: RUNNER_TOOL_CACHE=/tmp/probe-jsaction/<runner>
    setup-uv resolved:        /home/ywatanabe/.runner-toolcache

The override fails even for a plain shell step, so this is not a
setup-uv bug and not a JS-action quirk: the runner re-injects
RUNNER_TOOL_CACHE from AGENT_TOOLSDIRECTORY when it builds each step's
environment, after merging the user's env:. No workflow-layer override
of that variable can win. The existing fix is configured, visible,
believed to work, and inert -- which is worse than absent, because its
presence stopped anyone looking further.

WHAT THIS COMMIT DOES. It does not try to fix the variable again. It
removes the TRIGGER instead. The destructive writer runs only on a cache
MISS; on a HIT the action calls find() + addPath and writes nothing.
Unpinned, `version` resolves to `latest`, so an upstream release turns
every concurrent job into a simultaneous miss -- exactly the storm the
original comment describes. Pinning makes every job a hit.

The shared cache is healthy right now (probe: `which uv` ->
/home/ywatanabe/.runner-toolcache/uv/0.12.0/x86_64/uv, uv 0.12.0), so
pinning to 0.12.0 pins to a good tree rather than freezing a broken one.

This is a mitigation and is labelled as one in the comment. The HAZARD
-- one mutable directory shared by every runner -- is only fixable where
the value is set, in the runner supervisor, by giving each runner its
own AGENT_TOOLSDIRECTORY. That is filed for scitex-hpc, who own the
runners. Both belong in place; neither replaces the other.

Independently justified: an unpinned toolchain silently changes what CI
runs whenever an upstream release lands, which is the opposite of a
reproducible build.

Bumping the pin re-opens the window for one run, so the comment says to
warm the new version on the pool first, or bump while it is idle.
@ywatanabe1989
ywatanabe1989 merged commit 4fc76af into main Jul 30, 2026
1 check passed
@ywatanabe1989
ywatanabe1989 deleted the fix/pin-uv-to-stop-toolcache-miss-storm branch July 30, 2026 18:46
ywatanabe1989 added a commit that referenced this pull request Jul 31, 2026
…day's pool (#26)

Operator ruling 2026-07-31: GitHub-hosted runners are the DEFAULT CHOICE for
new work. Most scitex repositories are public, so hosted minutes are free and
the cost objection that justified blanket self-hosting mostly does not apply.
Self-hosted stays for jobs where our own machines are genuinely faster -- long
builds, GPU, large data -- chosen because it wins, not by policy.

BACKWARD COMPATIBLE BY CONSTRUCTION. The new `runs_on` input defaults to
'["self-hosted","Linux","X64","spartan-cpu"]', byte-identical to the value that
was hardcoded, so all 28 callers keep exactly today's behaviour. Only a repo
that passes the input moves.

WHY A CALLER WANTS OUT TODAY. Every runner on the Spartan lease inherits one
mutable ~/.runner-toolcache on GPFS, so runners contend for a single directory.
Jobs fail BOTH reading it (`uv: No such file` -> exit 127) and writing it
(ENOENT during copyfile INTO the cache) -- one bug seen from opposite ends.
Measured on scitex-cards 2026-07-31: seven failures across five pull requests
in one afternoon, four differently-named runners, every one cleared by a plain
rerun. This workflow's own comments already describe the hazard, and #24 records
that the per-runner fix "cannot fire".

The durable fix is still a per-runner AGENT_TOOLSDIRECTORY in the supervisor.
This input is not that fix; it is an escape hatch for callers that cannot
absorb random reruns while that work is pending.

THE FORK GUARD NEEDED A REAL FIX, NOT A TEST UPDATE. Parameterising `runs-on`
made the guard's selector stop recognising this job: it matched the literal
label "self-hosted", and an expression is not that string. The job silently left
the guarded set, so the fork guard would have become unenforced AND untested in
the same moment -- the suite would have stayed green while the protection
disappeared. The tests caught it.

_may_run_self_hosted now answers YES for an expression-valued `runs-on`, because
such a job CAN resolve to self-hosted and its default here does. Erring towards
guarded is the only safe direction: a false yes costs one harmless step on a
hosted runner, a false no removes a security boundary quietly.

74 passed, up from 72.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant