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
Nobody has claimed this yet.
- Dominant language
- C#
- Stars
- 6.3k
- Forks
- 1.4k
- Avg merge
- 1d 16h
- Merged PRs (30d)
- 24
Description
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 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/homeis a bind mount of_work/_temp/_github_home(ContainerOperationProvider.cs), so container steps write their$HOMEstate into the per-job temp directory.- The worker deletes all of
_tempat job start and end (TempDirectoryManager.cs) withcontinueOnContentDeleteError: 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 (moby/moby#52892);
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).
- 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.04
steps:
- 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.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:
$ docker run --rm ubuntu:24.04 bash -c umask # container init
0022
$ docker exec <container> bash -c umask # job-step path, Docker 29.6
0000
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.
Contributor guide
No contributing guide indexed for this repository
First steps
- Read the whole issue, then the project's contributing guide.
- Comment on the issue to say you are picking it up — it saves two people doing the same work.
- Fork the repository and make your change on a branch.
- Open a pull request that references the issue number.
Research direction
Start with ContainerOperationProvider.cs and TempDirectoryManager.cs, then trace deletion behavior through IOUtil.cs. Reproduce the two-job demo on Docker 29.6 and compare it with an unaffected Docker version. Done means cleanup no longer leaves a partially deleted _github_home tree, and failures to delete entries are surfaced rather than silently skipped.
Written by the indexing model from the issue text.
Assessment
- Tech stack
- csharp, docker
- Domain
- ci-cd, devops, infrastructure
- Issue type
- Bug
- Difficulty
- 4/5
- Estimated time
- 3-5 days
- Activity status
- Quiet
- Clarity
- Mostly clear
- Newbie friendliness
- 52/100