You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
Temp cleanup half-deletes root-owned trees in _github_home under Docker ≥ 29.6 (exec umask 0000), corrupting Bazel's install base on persistent runners #4603
On persistent self-hosted runners, the worker's temp-directory cleanup partially deletes tool state that container steps wrote into $HOME (/github/home). Under Docker ≥ 29.6 this leaves Bazel's install base half-deleted, and every subsequent containerized Bazel job on that runner fails at startup with FATAL: corrupt installation. The job that plants the damage succeeds; a later, unrelated job fails; no log mentions the deletion. The runner stays broken until the directory is removed manually.
Root cause — three long-standing behaviors and one recent Docker change:
/github/home is a bind mount of _work/_temp/_github_home (ContainerOperationProvider.cs), so container steps write their $HOME state into the per-job temp directory.
The worker deletes all of _temp at job start and end (TempDirectoryManager.cs) with continueOnContentDeleteError: true — undeletable entries are skipped silently (IOUtil.cs).
The worker runs as the non-root service user, but container-created files are root-owned. Unlinking requires write permission on the parent directory, so the root-owned tree's directory modes decide what the cleanup can delete.
Docker 29.6 clears the daemon umask (daemon: Set umask to 0000 moby/moby#52892); docker exec sessions — every container job step — inherit umask 0000, so directories created with mkdir(0777) are now world-writable instead of 0755.
Bazel's install base mixes both modes: outer directories from mkdir(0777) & ~umask, inner content explicitly chmod'ed read-only (umask-independent).
Docker ≤ 29.5: outer dirs are root-owned 0755, the cleanup can delete nothing, the install base survives intact.
Docker ≥ 29.6: outer dirs are 0777, the cleanup deletes the top-level files (A-server.jar, …) but not the contents of the read-only inner dirs — leaving a husk Bazel refuses to start on rather than repair.
Full protection (old) and full deletion (a root cleanup) would both be harmless; only the new partial outcome is fatal, and it recurs on every job.
To Reproduce
Steps to reproduce the behavior (no Bazel needed), on a persistent self-hosted runner with Docker ≥ 29.6:
Run this container job:
jobs:
demo:
runs-on: [self-hosted, <persistent-label>]container:
image: ubuntu:24.04steps:
- run: | if [ -d "$HOME/.cache/demo" ]; then echo "=== state left by previous job + cleanup:" ls -laR "$HOME/.cache/demo" fi mkdir -p "$HOME/.cache/demo/inner" touch "$HOME/.cache/demo/top.file" "$HOME/.cache/demo/inner/inner.file" chmod 555 "$HOME/.cache/demo/inner"
Run the same job again on the same runner.
The second run's listing shows top.file deleted while inner/inner.file survives — the half-delete. On Docker ≤ 29.5 the tree is fully intact instead.
The umask difference is directly observable on the runner host:
Replacing the demo tree with bazelisk build //... reproduces the real failure: job 1 succeeds (fresh extraction of Bazel's install base), job 2 fails with FATAL: corrupt installation.
Expected behavior
Tool state under /github/home is either removed completely between jobs or left intact — and when the cleanup cannot delete entries, that is surfaced (a warning listing skipped paths) instead of silently leaving a partially-deleted tree that breaks later jobs.
Runner Version and Platform
2.335.1
Linux x86_64, self-hosted, persistent (non-ephemeral), runner service running as a non-root user. Docker Engine 29.6.2 (29.3.1 unaffected). container: jobs running as root (default, no userns remapping).
What's not working?
Every containerized Bazel job on a poisoned runner fails immediately:
FATAL: corrupt installation: file '/github/home/.cache/bazel/_bazel_root/install/<md5>/A-server.jar' is missing or modified.
Please remove '/github/home/.cache/bazel/_bazel_root/install/<md5>' and try again.
##[error]Process completed with exit code 36.
On-disk state of the half-deleted install base (host view; all top-level files deleted, read-only subdirs stranded):
/…/_work/_temp/_github_home/.cache/bazel/_bazel_root/install/<md5>/
├── embedded_tools/ (r-xr-xr-x, contents intact)
└── platforms/ (r-xr-xr-x, contents intact)
# A-server.jar and all other top-level files: gone
Job Log Output
Included above. The job that causes the damage completes with result: Succeeded and logs nothing unusual.
Runner and Worker's Diagnostic Logs
The _diag logs contain no trace of the skipped deletions — continueOnContentDeleteError: true swallows the failures, which is part of the problem. Happy to provide full Runner_/Worker_ logs for an affected job pair on request.
Describe the bug
On persistent self-hosted runners, the worker's temp-directory cleanup partially deletes tool state that container steps wrote into
$HOME(/github/home). Under Docker ≥ 29.6 this leaves Bazel's install base half-deleted, and every subsequent containerized Bazel job on that runner fails at startup withFATAL: corrupt installation. The job that plants the damage succeeds; a later, unrelated job fails; no log mentions the deletion. The runner stays broken until the directory is removed manually.Root cause — three long-standing behaviors and one recent Docker change:
/github/homeis a bind mount of_work/_temp/_github_home(ContainerOperationProvider.cs), so container steps write their$HOMEstate into the per-job temp directory._tempat job start and end (TempDirectoryManager.cs) withcontinueOnContentDeleteError: true— undeletable entries are skipped silently (IOUtil.cs).docker execsessions — every container job step — inherit umask 0000, so directories created withmkdir(0777)are now world-writable instead of 0755.Bazel's install base mixes both modes: outer directories from
mkdir(0777) & ~umask, inner content explicitly chmod'ed read-only (umask-independent).A-server.jar, …) but not the contents of the read-only inner dirs — leaving a husk Bazel refuses to start on rather than repair.Full protection (old) and full deletion (a root cleanup) would both be harmless; only the new partial outcome is fatal, and it recurs on every job.
To Reproduce
Steps to reproduce the behavior (no Bazel needed), on a persistent self-hosted runner with Docker ≥ 29.6:
top.filedeleted whileinner/inner.filesurvives — the half-delete. On Docker ≤ 29.5 the tree is fully intact instead.The umask difference is directly observable on the runner host:
Replacing the demo tree with
bazelisk build //...reproduces the real failure: job 1 succeeds (fresh extraction of Bazel's install base), job 2 fails withFATAL: corrupt installation.Expected behavior
Tool state under
/github/homeis either removed completely between jobs or left intact — and when the cleanup cannot delete entries, that is surfaced (a warning listing skipped paths) instead of silently leaving a partially-deleted tree that breaks later jobs.Runner Version and Platform
2.335.1
Linux x86_64, self-hosted, persistent (non-ephemeral), runner service running as a non-root user. Docker Engine 29.6.2 (29.3.1 unaffected).
container:jobs running as root (default, no userns remapping).What's not working?
Every containerized Bazel job on a poisoned runner fails immediately:
On-disk state of the half-deleted install base (host view; all top-level files deleted, read-only subdirs stranded):
Job Log Output
Included above. The job that causes the damage completes with result: Succeeded and logs nothing unusual.
Runner and Worker's Diagnostic Logs
The
_diaglogs contain no trace of the skipped deletions —continueOnContentDeleteError: trueswallows the failures, which is part of the problem. Happy to provide fullRunner_/Worker_logs for an affected job pair on request.