## TL;DR
"Permission denied" on the checkout step in GitHub Actions is usually the runner user lacking write access to the workspace directory, or a container job running as the wrong UID. Check who owns the workspace files and which user the job runs as; the fix is typically `chown` in a pre-step, running the container as the right user, or letting actions/checkout handle permissions with its defaults.

## Error / query
```text
GitHub Actions "permission denied" on checkout fix
```

## Use this skill when
- `actions/checkout` fails with `permission denied` or `unable to create directory`
- A step after checkout cannot write to `$GITHUB_WORKSPACE`
- A container job fails on file operations the non-container job handled fine
- Self-hosted runners fail where GitHub-hosted runners succeed

## Not for this skill when
- Checkout fails with auth errors (bad token, repo not found; that is credentials, not filesystem permissions)
- The failure is `Filename too long` or disk-full (filesystem limits, not permissions)
- Steps fail with permission denied on `/usr` or system paths (container user config, different fix)
- You are debugging permissions inside a deployed app, not the CI workspace

## Steps

### Step 1: Find the failing step and the exact path
```bash
echo "workspace=$GITHUB_WORKSPACE runner=$RUNNER_OS"
ls -la "$GITHUB_WORKSPACE" 2>&1 | head -10
whoami; id
```
Expected: you see which user the job runs as and who owns the workspace directory. A mismatch (e.g. files owned by root, job running as `runner`) is the classic cause.

### Step 2: Check for a container user mismatch
```bash
grep -n "container:" -A 5 .github/workflows/[workflow].yml
```
Expected: if the job uses `container:` with an image whose default user is root, files get created as root while later non-container steps run as `runner`. Align them with the `options: --user` setting or run everything in the container.

### Step 3: Fix ownership before checkout on self-hosted runners
```bash
sudo chown -R $(whoami) "$GITHUB_WORKSPACE" 2>/dev/null || chown -R $(whoami) "$GITHUB_WORKSPACE"
```
Expected: as a pre-checkout step (or in the runner setup), this makes the workspace writable. On self-hosted runners, leftover root-owned files from a previous container run are the usual culprit.

### Step 4: Let checkout clean instead of fighting stale files
```bash
grep -n -A 8 "actions/checkout" .github/workflows/[workflow].yml
```
Expected: the checkout step is visible. Adding `clean: true` makes checkout remove untracked workspace content first, which clears root-owned leftovers that block the fresh checkout.

### Step 5: Re-run and confirm the workspace is writable end to end
```bash
touch "$GITHUB_WORKSPACE/.perm-check" && echo "workspace writable"
```
Expected: as a workflow step right after checkout, this proves the fix. If it still fails, the problem is the runner service account itself (check how the self-hosted runner service is installed and which user it runs as).

## Variant phrasings

### "actions/checkout fails EACCES"
Same filesystem-permission problem. Steps 1-3 identify the owner mismatch.

### "permission denied in github actions docker container"
The container user vs the `runner` user mismatch. Set the container user explicitly or chown the workspace in an init step.

### "self hosted runner permission denied checkout"
Almost always stale root-owned files from a previous run. A chown pre-step or `clean: true` on checkout fixes it permanently.

## Why it happens
The runner checks out code as one OS user, but container steps, sudo usage, or previous runs can leave files owned by another user (often root). Checkout then cannot overwrite or create files. GitHub-hosted runners start clean every time, which is why the same workflow passes there and fails on a persistent self-hosted runner.

## Edge cases and pitfalls
- `sudo` in one step creates root-owned files that break later non-sudo steps; avoid sudo or chown afterward.
- Docker-in-Docker mounts the workspace into containers as root by default; files created there come back root-owned.
- `persist-credentials: false` is unrelated to file permissions; do not conflate token issues with EACCES.
- On Windows runners the error looks different (`Access is denied`); check antivirus locks and path length instead.
- Changing the runner service user on an existing self-hosted runner requires re-registering or updating the service config, not just the workflow.

## Provenance

Resolved from the public thread: https://vectle.com/posts/pst_D9r4kDDhMZx8u827xHEYvQ
