Skip to content

Persistent self-hosted runner: stale tailscale.tgz causes "Destination file path … already exists" #294

@jgertm

Description

@jgertm

The README says persistent self-hosted runners are supported and that the action "leaves tailscale binaries installed but stops the tailscale background processes" (added in #206). However, on persistent runners where GitHub's actions/cache backend doesn't return a cache hit, the action still fails on the second job with:

Downloading https://pkgs.tailscale.com/stable/tailscale_<version>_amd64.tgz
Destination file path /home/<user>/.cache/tailscale.tgz already exists
##[error]Destination file path /home/<user>/.cache/tailscale.tgz already exists

(repeated 3× by the retry helper)

Repro

  • Persistent self-hosted runner where $HOME is preserved across jobs (Aspect Workflows in our case).
  • cache.restoreCache(...) from @actions/cache does not hit (no Found Tailscale … in cache line in the logs — it goes straight to Downloading …). This happens whenever the runner platform doesn't expose a working GH Actions cache backend.
  • Run two consecutive jobs that use this action. The second one fails as above.

Real-world failure: https://github.com/sandbox-quantum/yak/actions/runs/25177419595/job/73813221947 (logs, reproduced on v4.1.2).

Root cause

src/main.ts#L432-L434:

const tarDest = path.join(xdgCacheDir(), "tailscale.tgz");
fs.mkdirSync(path.dirname(tarDest), { recursive: true });
const tarPath = await tc.downloadTool(downloadUrl, tarDest);

tc.downloadTool from @actions/tool-cache refuses to overwrite an existing destination (sourcedownloadToolAttempt throws if fs.existsSync(dest)). The action only avoids hitting this code path when the cloud cache restore succeeds, so on runners without a working cache backend the leaked .tgz from the previous job collides on every subsequent job.

#206 addressed a related but distinct bug (lingering tailscaled daemon → Text file busy); the tarball-leak case wasn't covered. The Windows MSI path has a needsDownload check (L538), but the Linux path is unconditional.

Suggested fix

Either of:

  1. Pre-clean the destination before downloading:
    fs.rmSync(tarDest, { force: true });
    const tarPath = await tc.downloadTool(downloadUrl, tarDest);
  2. Pass no dest argument so tc.downloadTool uses a fresh temp path each time, then delete it after extraction.
  3. Mirror the Windows needsDownload pattern: if the .tgz already exists and its sha256 matches config.sha256Sum, skip the download.

Happy to send a PR if option 1 or 3 is acceptable.

Workaround

Add a step before the action:

- run: rm -f "${XDG_CACHE_HOME:-$HOME/.cache}/tailscale.tgz"

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions