#6180·act

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

Author: HusskrCreated Sep 5, 2026Updated Sep 5, 2026

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) in removeGitIgnore — 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 leave Config.ActionCache nil get the shared-worktree path. (#6028 describes a separate race inside GoGitActionCache itself, 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.