removeGitIgnore races between concurrent jobs sharing an action-cache entry: 'remove …/.gitignore: no such file or directory' fails the job before the action is copied
Bug report
Describe the bug
With more than one job running concurrently, two jobs that load the same remote action (e.g. actions/checkout@v4) within ~100 ms both operate on the single cached working tree at <ActionCacheDir>/<hash>/. maybeCopyToActionDir calls removeGitIgnore (pkg/runner/action.go), which is stat-then-remove:
if _, err := os.Stat(gitIgnorePath); err == nil {
err := os.Remove(gitIgnorePath) // the loser of the race gets ENOENT here
if err != nil {
return err
}
}
Both jobs pass the Stat; the first Remove succeeds; the second returns ENOENT and the Main step fails before CopyDir has populated the per-job action dir:
⭐ Run Main actions/checkout@v4
❌ Failure - Main actions/checkout@v4
remove /root/.cache/act/<hash>/.gitignore: no such file or directory
⭐ Run Post actions/checkout@v4
Error: Cannot find module '/var/run/act/actions/<hash>/dist/index.js'
Total job time ≤4 s; the Cannot find module on the Post step is a consequence (the copy never happened), not the cause.
A second shape of the same shared-worktree race shows up inside CopyDir's tar walk when the sibling job's Force checkout rewrites the tree mid-walk — seen with astral-sh/setup-uv@v5:
❌ Failure - Main Install uv
lstat /root/.cache/act/<hash>/jest.config.js: no such file or directory
Observed
Gitea act_runner 0.6.1 (vendoring this package; the code is identical on nektos/act master and on gitea/act main, and act_runner v3.3.2 still takes this path because it never sets Config.ActionCache). On one repository's default branch: 8 such failures in 12 days, every one with a sibling task started within ±1 s (a push that triggers two workflows both starting with actions/checkout). Across the instance: 20 in 16 days. Every failure was transient — the sibling passed, and the next run passed with no intervention.
Expected behaviour
Concurrent jobs loading the same action should not fail because of each other.
Suggested fix
- Minimal: tolerate
os.IsNotExist(err)inremoveGitIgnore— the file being already gone is the desired end state. - Real: stop sharing a working tree between jobs.
GoGitActionCache(bare repo +GetTarArchive) already exists in this package; runners that leaveConfig.ActionCachenil get the shared-worktree path. (#6028 describes a separate race insideGoGitActionCacheitself, so that path would need its own fix before it is a drop-in.)
Workaround used
Replaced actions/checkout / setup-uv with run: steps (a depth-1 git fetch of $GITHUB_REF with the job token as an http.extraheader, then git checkout --detach $GITHUB_SHA; pip install uv==<pinned>), so the gated workflows never touch the action cache.
Source: nektos/act