GitHub Actions submodule checkout failed on a private submodule
GitHub Actions submodule checkout failed is a credential message rather than a git one: the outer repository clones cleanly and only the submodule fetch stops. The credential the action configured is scoped to the repository running the workflow, and a submodule usually lives somewhere else.

What this error means
The checkout step is green for several seconds, prints the submodule update command, starts cloning the first submodule and then stops. Git asks for a username, finds it cannot prompt because the runner has no terminal, and gives up. The outer repository is fully checked out on disk, which is what makes this read like a corrupt submodule pointer rather than a permissions problem. Remove submodules from the step and everything is green again, which is the strongest hint that nothing about the repository itself is wrong.
/usr/bin/git -c protocol.version=2 submodule update --init --force --depth=1 --recursive
Cloning into '<MY_SUBMODULE_REPO>'...
Error: fatal: could not read Username for 'https://github.com/': terminal prompts disabledA checkout that produces it
This workflow is written for this page and has never been run. It is the ordinary shape: a repository with one submodule that lives in a second private repository in the same organization, and a default checkout asked to bring submodules along. Nothing in it is deprecated and nothing is misspelled.
It fails because of what the action was given rather than what it was asked to do. With no token input, the action uses the automatic token, and the documentation for that token is explicit: "The token's permissions are limited to the repository that contains your workflow." A second repository is outside that limit however the permissions block is written.
name: ci
on: push
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v5
with:
submodules: recursive
- run: make buildCommon causes
The automatic token cannot read the submodule repository
The commonest cause and the one the error never names. GITHUB_TOKEN is an installation token whose permissions, in the documentation's words, are limited to the repository that contains your workflow. Raising permissions: contents: read does nothing here, because the limit is which repository, not which scope.
Credential persistence is switched off
With persist-credentials: false the action skips the whole of configureSubmoduleAuth, so no insteadOf rule and no core.sshCommand is written for any submodule. The outer clone still succeeds because the action fetches it directly, which is why this looks like a submodule-only fault.
An SSH URL with no key to match it
A .gitmodules entry starting git@github.com: is rewritten to HTTPS when no ssh-key is supplied, so the fetch then depends on the token like any other. If you expected SSH to be used, the failure message mentioning an HTTPS URL is the tell that the rewrite happened.
The submodule is nested and only one level was requested
Setting submodules: true initializes one level. A submodule inside a submodule is never fetched, so a later build step reports a missing directory rather than an authentication failure. Less common, and worth ruling out only after the credential is known to be right.
How to fix it
Give the step a token that can read the other repository
Create a fine-grained token or a GitHub App installation token with read access to the submodule repositories, store it as a secret and pass it as the token input. The action configures it for the outer repository and, through the insteadOf rule, for the submodules too.
- uses: actions/checkout@v5
with:
submodules: recursive
token: ${{ secrets.SUBMODULE_TOKEN }}Or supply a deploy key and keep the SSH URLs
- Add a deploy key to the submodule repository with read access.
- Store the private half as a secret in the repository that runs the workflow.
- Pass it as
ssh-key, which makes the action setcore.sshCommandinside each submodule. - Leave the
.gitmodulesURLs as SSH: with a key present they are no longer rewritten.
Check whether you turned persistence off
Search the workflow for persist-credentials. If it is false, either remove it for the job that needs submodules, or keep it and authenticate the submodules yourself before the fetch. Turning it back on for one job is usually the smaller risk, because the action removes the credential in its post-job step either way.
- uses: actions/checkout@v5
with:
submodules: recursive
persist-credentials: trueStandardize the URLs rather than working around them
Mixed SSH and HTTPS entries in one .gitmodules file mean two credential paths to keep working. Rewrite them to one scheme and commit the change, so CI and laptops agree. HTTPS is the easier one to support in CI because it uses the same token the rest of the job already has.
git config --file .gitmodules submodule.libfoo.url https://github.com/org/libfoo.git
git submodule sync
git add .gitmodules && git commit -m "use https for submodules"What the action configures, and when it does not
The submodule work is one method in the action, configureSubmoduleAuth() in src/git-auth-helper.ts. It clears whatever a previous run left behind, and then everything in it that configures a credential sits inside if (this.settings.persistCredentials). That is the branch worth knowing about: turn credential persistence off and the action still checks out your submodules, but it configures nothing for them, so every fetch that needs authentication fails. Teams who set persist-credentials: false for good security reasons meet this the moment a private submodule enters the repository.
When persistence is on, the method does one of two things. With an ssh-key input it runs a git config inside each submodule to point core.sshCommand at the key the action set up. Without one it adds an insteadOf rule per submodule so that SSH-style remotes are rewritten to HTTPS and the token applies to them. The README describes the same behavior from the outside: "When the ssh-key input is not provided, SSH URLs beginning with git@github.com: are converted to HTTPS."
async configureSubmoduleAuth(): Promise<void> {
// Remove possible previous HTTPS instead of SSH
await this.removeSubmoduleGitConfig(this.insteadOfKey)
if (this.settings.persistCredentials) {Which credential each URL shape needs
Two decisions decide the outcome, and people usually only make one of them consciously. The first is what the .gitmodules entry says. The second is which input the checkout step was given. The table pairs them.
The row that surprises people is the third one. An SSH URL with no ssh-key is rewritten to HTTPS and then authenticated with whatever token the step holds, so it can work for a public submodule and fail for a private one in another repository, with no mention of SSH anywhere in the error.
.gitmodules URL | Checkout input | Outcome |
|---|---|---|
https://github.com/org/pub | none | works, the submodule is public |
https://github.com/org/priv | none | fails, the automatic token is repository-scoped |
git@github.com:org/priv.git | none | rewritten to HTTPS, then fails for the same reason |
git@github.com:org/priv.git | ssh-key | works, core.sshCommand is set per submodule |
| any private URL | persist-credentials: false | fails, nothing is configured for submodules |
The two corrected shapes
A token with access to the submodule repository is the smaller change and keeps everything on HTTPS. Use a fine-grained personal access token or a GitHub App installation token with contents read on the submodule repositories, and keep it on a service account rather than a person. The README makes the same recommendation for the token input: "We recommend using a service account with the least permissions necessary."
A deploy key is the alternative, and it is the better one when the submodule belongs to a different owner or when you would rather not mint a cross-repository token. Supply it as ssh-key, and the action configures each submodule to use it. Keep submodules: recursive only if you have nested submodules; true is one level and is faster.
- uses: actions/checkout@v5
with:
submodules: recursive
token: ${{ secrets.SUBMODULE_TOKEN }}
# or, with a deploy key instead of a token
- uses: actions/checkout@v5
with:
submodules: recursive
ssh-key: ${{ secrets.SUBMODULE_DEPLOY_KEY }}Why there is no recorded run on this page
Recording this one would mean standing up a second private repository, pointing a submodule at it and minting a credential whose scope is the whole subject of the page. We are not going to publish a run whose interesting property is the shape of a credential boundary, and a redacted log of it would tell you nothing you cannot see in the one quoted above, which a reporter already published with their own redaction. The mechanism is also not observational: it is one branch in the action and one sentence about the automatic token's scope, both quoted here from the files that contain them. A green run after the fix would prove that our token worked, which is not a fact about anyone else's.
How to prevent it
- Assume the automatic token stops at the repository boundary and plan submodules around that.
- Keep one URL scheme in
.gitmodulesso CI and local clones need the same credential. - Pin the credential to a service account or an App, never to a person who can leave.
- Request
submodules: truerather thanrecursiveunless you really have nested ones.
Frequently asked questions
Why does the main repository check out but the submodule fail?
Does persist-credentials false break submodule checkout?
configureSubmoduleAuth method does all of its credential configuration inside a check on that setting, so with it false no credential helper and no SSH command is configured for any submodule. The outer checkout still succeeds, which makes this easy to misread.Do I need to change .gitmodules to use an SSH deploy key?
ssh-key input and leave the SSH URLs in place. The rewrite to HTTPS only happens when no key is provided, so with a key present the entries are used as written and each submodule is configured to use that key.Can I give GITHUB_TOKEN access to another repository?
permissions block. That block adjusts scopes within the repository the workflow belongs to. Reading a second repository needs a different credential: a personal access token, a GitHub App installation token, or a deploy key on the repository you need to read.Related guides
References
- actions/checkout: README, submodules and ssh-key inputs
- actions/checkout#738: terminal prompts disabled when submodules: true
- GitHub Actions: about the GITHUB_TOKEN and its scope
- Git: gitmodules, the submodule URL entry
- actions/checkout: git-auth-helper.ts, configureSubmoduleAuth
- Git reference manual
- GitHub Actions documentation