actions/checkout: shallow history, a persisted token, and the v7 fork block
actions/checkout clones the repository that triggered the workflow into $GITHUB_WORKSPACE, one commit deep, and leaves the job token configured so later steps can run authenticated git commands. Since v7 (v7.0.1 is current) it also refuses to check out fork pull request code in pull_request_target and workflow_run workflows unless you set allow-unsafe-pr-checkout: true.
Almost every workflow starts with actions/checkout, and most copy it with no with: block at all. That works until a job needs a tag, a second repository, a private submodule or a git push, and then the defaults you never chose start to matter: one commit of history, no tags, and a token left on disk for every step that follows.

The defaults you never chose
The table is action.yml at the v7.0.1 tag, with each description shortened. Two outputs come back: ref, the branch, tag or SHA that was checked out, and commit, the SHA. When git 2.18 or newer is not on PATH, which happens in some container jobs, the README says the action falls back to downloading the files through the REST API, and you get files without a .git directory.
The pinned step below is the version this page was checked against. Pin to the full SHA rather than @v7 and the next change reaches you through a reviewed pull request instead of silently on the next run, which is the lesson of the backport described in the next section.
permissions:
contents: read
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false| Input | Default | What it does |
|---|---|---|
repository | ${{ github.repository }} | Owner and name of the repository to clone |
ref | The triggering ref or SHA; otherwise the default branch | Branch, tag or SHA to check out |
token | ${{ github.token }} | Token used to fetch, and left configured for later git commands |
ssh-key | None | SSH key used instead of the token |
ssh-known-hosts | None | Extra known hosts; github.com is always added |
ssh-strict | true | Adds StrictHostKeyChecking=yes and CheckHostIP=no |
ssh-user | git | User for the SSH host |
persist-credentials | true | Keep the token or key configured after the step |
path | Workspace root | Relative path under $GITHUB_WORKSPACE |
clean | true | Runs git clean -ffdx && git reset --hard HEAD before fetching |
filter | None | Partial clone filter; overrides sparse-checkout |
sparse-checkout | None | Patterns to check out, one per line |
sparse-checkout-cone-mode | true | Cone mode for those patterns |
fetch-depth | 1 | Commits to fetch; 0 is all history for all branches and tags |
fetch-tags | false | Fetch tags even when fetch-depth is above 0 |
show-progress | true | Progress output while fetching |
lfs | false | Download Git LFS files |
submodules | false | true, or recursive for nested submodules |
set-safe-directory | true | Adds the path to git's global safe.directory |
github-server-url | The instance running the workflow | Base URL for GitHub Enterprise Server |
allow-unsafe-pr-checkout | false | New in v7: allow fork pull request code under pull_request_target or workflow_run |
v7 is the major that fails jobs
Two of the four majors are runtime moves that only matter on self-hosted runners, which need runner v2.327.1 or newer for node24. The v6 change moved the persisted credential out of .git/config into its own file and is invisible to most workflows; v6.0.1 fixed the one case it broke, git worktrees.
v7 is the one that fails jobs. In a workflow triggered by pull_request_target, or by workflow_run when the upstream run was a pull request event, checkout now refuses fork pull request code: a repository that resolves to the fork, a ref of refs/pull/<n>/head or refs/pull/<n>/merge, or a ref that resolves to the fork's head or merge SHA. The step fails with "Refusing to check out fork pull request code from a 'pull_request_target' workflow. This workflow runs with the base repository's GITHUB_TOKEN, secrets, default-branch cache scope, and runner access."
GitHub backported the refusal on 2026-07-20 to v4.4.0, v5.1.0, v6.1.0, v3.7.0 and v2.8.0, so a workflow on a floating @v4 picked it up that day, and one pinned to a SHA did not. Same-repository pull requests and the pull_request event are unaffected. GitHub's changelog is explicit about the limits: a run: step that fetches the head itself, or an issue_comment trigger, is not blocked.
on: pull_request_target
jobs:
test:
runs-on: ubuntu-latest
steps:
# Refused on v7, and on the backported v2 to v6 tags, for a fork pull request
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
ref: ${{ github.event.pull_request.head.sha }}| Major | Released | Runtime | What changed |
|---|---|---|---|
| v4.0.0 | 2023-10-17 | node20 | Default runtime moved from node16; show-progress input added |
| v5.0.0 | 2025-08-11 | node24 | Needs runner v2.327.1 or later |
| v6.0.0 | 2025-11-20 | node24 | persist-credentials writes a separate file under $RUNNER_TEMP, pulled in by an includeIf line in .git/config; Docker container actions need runner v2.329.0 for authenticated git; v6.0.1 added git worktrees |
| v7.0.0 | 2026-06-18 | node24 | Refuses fork pull request code in pull_request_target and workflow_run; allow-unsafe-pr-checkout opts out; internals moved to ESM. Latest is v7.0.1 |
fetch-depth, fetch-tags and git describe
The default fetch is one commit with --no-tags, so a fresh checkout has no tags and no parent. fetch-depth: 0 fetches every branch and every tag. fetch-depth: 2 is what the README uses to reach HEAD^. In between sits the case people trip on: fetch-tags: true with a positive depth adds +refs/tags/*:refs/tags/* to the fetch, so the tag names arrive, but each at the same shallow depth, and nothing connects them to HEAD.
We reproduced the three cases with the refspecs checkout builds, against a repository tagged v1.0.0 two commits back. Only full history lets git describe find the tag, so any versioning tool that derives a version from tags needs fetch-depth: 0. That costs real time on a large repository; the checkout speed page measures what full history and LFS add.
# fetch-depth: 1 (the default), no tag refspec
$ git describe --tags
fatal: No names found, cannot describe anything.
# fetch-depth: 1 with fetch-tags: true
$ git describe --tags
fatal: No tags can describe 'cc8f8665ee9a7f3ba421568696685a814358835e'.
Try --always, or create some tags.
# fetch-depth: 0
$ git describe --tags
v1.0.0-2-gcc8f866persist-credentials, and why false is the safer default
With persist-credentials: true, the default, the token you fetched with stays configured after the step. Up to v5 it was written into .git/config inside the checkout. Since v6 it sits in a file under $RUNNER_TEMP that .git/config includes, and the post-job step removes it either way. Until then, every later step in the job, including third-party actions and dependency install scripts, can run authenticated git commands as that token.
The old location had a second cost. In August 2024, Palo Alto Networks' Unit 42 published ArtiPACKED: workflows that uploaded their checkout directory as an artifact shipped .git/config, token included, and public repositories serve artifacts to anyone. actions/upload-artifact v4.4.0 stopped including hidden files by default the same month.
Set persist-credentials: false on any job that only reads the repository, and keep permissions: contents: read at the top. Leave it on only in the job that pushes, and grant that job the write permission it needs. A push refused after checkout is almost always this setting meeting that permission, which the persisted credentials 403 page walks through.
jobs:
test:
runs-on: ubuntu-latest
permissions:
contents: read
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- run: npm ci && npm test
tag:
needs: test
runs-on: ubuntu-latest
permissions:
contents: write
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- run: git tag "build-${{ github.run_number }}" && git push origin --tagsSubmodules, sparse checkout and LFS
submodules: true fetches the first level and recursive fetches nested ones, with the same credential as the main repository. The default token is scoped to the repository running the workflow, so a private submodule anywhere else fails. Without ssh-key, URLs beginning with git@github.com: are rewritten to HTTPS, which means the token, not a key, has to be able to read them. Pass a token that can read both repositories, or an SSH deploy key; the submodule page has both.
sparse-checkout takes one pattern per line and defaults to cone mode, which matches directories; a single file needs sparse-checkout-cone-mode: false. In the v7.0.1 source a sparse checkout also turns on a partial clone with --filter=blob:none, unless you set filter yourself, which overrides sparse-checkout entirely. lfs is off by default, so LFS pointers check out as small text files until you turn it on.
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
sparse-checkout: |
.github
src
persist-credentials: falseChecking out another repository
Set repository and give each checkout its own path, or the second one replaces the first. Without ref the other repository checks out its default branch. A public repository needs nothing else. A private one needs a token that can read it, because ${{ github.token }} cannot, and that token is persisted like any other, so add persist-credentials: false unless a later step pushes there.
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
path: main
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
repository: my-org/my-private-tools
token: ${{ secrets.TOOLS_READ_TOKEN }}
path: tools
persist-credentials: falseDetached HEAD, refused pushes and fork blocks
Each of these has a page with the causes ranked and the fixes in order. The two without a page are short enough to answer here.
| What you see | What it means | Where to go |
|---|---|---|
A commit prints [detached HEAD <sha>], then the push stops | On a pull request, or any ref that is not a branch, HEAD is a bare commit | Detached HEAD after actions/checkout |
A later git push is refused for github-actions[bot], git exits 128 | The persisted token lacks the permission the push needs | Persisted credentials push 403 |
| The submodule fetch asks for a username and gives up | The token cannot read the submodule's repository | Private submodule checkout failed |
Refusing to check out fork pull request code from a 'pull_request_target' workflow. | v7 or a backported tag blocked a fork checkout in a privileged workflow | Run untrusted code under pull_request instead; opt out only after review |
The ref '...' does not point to the expected commit '...'. | The branch or tag moved between the trigger and the fetch | Re-run, or check out the SHA rather than the ref |
Key takeaways
- The default is one commit, no tags and a persisted token; change all three deliberately.
- v7 refuses fork pull request code in
pull_request_targetandworkflow_run, and the backport reached every floating tag from v2 to v6. fetch-tags: trueat depth 1 brings tag names but not the historygit describeneeds; usefetch-depth: 0.- Set
persist-credentials: falseon every job that does not push.
Frequently asked questions
Does fetch-tags: true actually fetch any tags?
fetch-tags: true checkout adds +refs/tags/*:refs/tags/* to the fetch, after a tag handling fix in v6.0.2. Issue 1471 was filed against the earlier behavior, where it fetched nothing unless a tag triggered the run. The tags still arrive at your fetch-depth, so anything that walks history from HEAD to a tag needs fetch-depth: 0.Should I set persist-credentials to false?
contents: write permission.How do I check out a private repository with actions/checkout?
token that can read it, plus repository and a separate path. The default ${{ github.token }} is scoped to the repository running the workflow, so it cannot read another private one. A GitHub App installation token or a fine-grained token with read access to that repository both work.Why does checkout fail with "could not read Username for https://github.com: terminal prompts disabled"?
persist-credentials: false. The submodule page linked above covers the first case end to end.