Skip to content
Latchkey LogoLatchkey home

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.

Four panels for checkout v4 to v7: runtime, what changed, where the token lives, and the 2026 backport
What each major of actions/checkout changed. The v7 refusal was also backported to v2 through v6 on 2026-07-20, so floating tags picked it up.

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.

.github/workflows/ci.yml
permissions:
  contents: read
steps:
  - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
    with:
      persist-credentials: false
InputDefaultWhat it does
repository${{ github.repository }}Owner and name of the repository to clone
refThe triggering ref or SHA; otherwise the default branchBranch, tag or SHA to check out
token${{ github.token }}Token used to fetch, and left configured for later git commands
ssh-keyNoneSSH key used instead of the token
ssh-known-hostsNoneExtra known hosts; github.com is always added
ssh-stricttrueAdds StrictHostKeyChecking=yes and CheckHostIP=no
ssh-usergitUser for the SSH host
persist-credentialstrueKeep the token or key configured after the step
pathWorkspace rootRelative path under $GITHUB_WORKSPACE
cleantrueRuns git clean -ffdx && git reset --hard HEAD before fetching
filterNonePartial clone filter; overrides sparse-checkout
sparse-checkoutNonePatterns to check out, one per line
sparse-checkout-cone-modetrueCone mode for those patterns
fetch-depth1Commits to fetch; 0 is all history for all branches and tags
fetch-tagsfalseFetch tags even when fetch-depth is above 0
show-progresstrueProgress output while fetching
lfsfalseDownload Git LFS files
submodulesfalsetrue, or recursive for nested submodules
set-safe-directorytrueAdds the path to git's global safe.directory
github-server-urlThe instance running the workflowBase URL for GitHub Enterprise Server
allow-unsafe-pr-checkoutfalseNew 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.

.github/workflows/pr-target.yml
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 }}
MajorReleasedRuntimeWhat changed
v4.0.02023-10-17node20Default runtime moved from node16; show-progress input added
v5.0.02025-08-11node24Needs runner v2.327.1 or later
v6.0.02025-11-20node24persist-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.02026-06-18node24Refuses 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.

Terminal
# 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-gcc8f866

persist-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.

.github/workflows/ci.yml
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 --tags

Submodules, 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.

.github/workflows/lint.yml
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
  with:
    sparse-checkout: |
      .github
      src
    persist-credentials: false

Checking 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.

.github/workflows/ci.yml
- 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: false

Detached 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 seeWhat it meansWhere to go
A commit prints [detached HEAD <sha>], then the push stopsOn a pull request, or any ref that is not a branch, HEAD is a bare commitDetached HEAD after actions/checkout
A later git push is refused for github-actions[bot], git exits 128The persisted token lacks the permission the push needsPersisted credentials push 403
The submodule fetch asks for a username and gives upThe token cannot read the submodule's repositoryPrivate 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 workflowRun 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 fetchRe-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_target and workflow_run, and the backport reached every floating tag from v2 to v6.
  • fetch-tags: true at depth 1 brings tag names but not the history git describe needs; use fetch-depth: 0.
  • Set persist-credentials: false on every job that does not push.

Frequently asked questions

Does fetch-tags: true actually fetch any tags?
On current releases, yes: with 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?
On any job that does not push, yes. The default leaves the token usable by every later step until the job ends, and the long-running request to flip the default (issue 485) has not changed it. Keep it on only in the job that commits or tags, with the matching contents: write permission.
How do I check out a private repository with actions/checkout?
Pass a 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"?
Git needed a credential and had none it could use, and the runner cannot prompt. It usually means a submodule or second repository the token cannot read, or a later git command after you set persist-credentials: false. The submodule page linked above covers the first case end to end.

References

The same checkout step, on a VM that ends with the job. That is a Latchkey runner. Start free → 30-day trial · No credit card