Skip to content

Troubleshooting

Every entry here is an error the action or the API actually emits. The message is quoted as it appears in the log.

Inputs and setup

these inputs do not apply to target: <target>

The run passed an input belonging to a different target. The log names each one and where it belongs, and lists all of them at once so you fix them in one pass rather than one per run.

This fires before the checkout, so it costs a couple of seconds. Check the "Applies to" column in the action reference, or drop the input.

There's one thing this can't catch: an input set explicitly to the value it already defaults to. GitHub gives a composite action no way to tell "unset" from "set to the default". An input holding its default changes nothing, so the blind spot is harmless.

unknown target '<value>'

target accepts github-pages, s3-cloudfront, lambda-zip, terragrunt, ansible or cloudflare-workers. The message lists them. There's no default, on purpose: guessing github-pages would silently build a site for someone who meant to deploy a Lambda.

Tremvok skipped: this pull request comes from a fork

Working as designed. A fork can't read your secrets, so the credential is empty and the deploy would fail with an authentication error that looks like a broken credential rather than a policy doing its job. The run reports a skip with the reason instead.

allow-fork-preview: true exists and is almost always wrong: it converts an honest skip into an auth error.

Tremvok skipped: no AWS credential is available for target: <target>

Only s3-cloudfront and lambda-zip raise this, the two targets that call AWS themselves. Set aws-role-to-assume, or configure credentials in an earlier step.

No other target sees it. github-pages, cloudflare-workers and ansible publish somewhere else, and terragrunt takes its credentials from the backend and provider blocks in your own configuration, which may name AWS, Azure, GCP, a private cloud, or several in one run. Skipping any of them for a missing AWS credential skips a run that never needed one. If a terragrunt run does need AWS and has none, terragrunt fails in the provider with a message naming it, which is the more useful error. Before this was fixed, a terragrunt run with no aws-role-to-assume was skipped outright: every terragrunt step is gated on this skip, so the target quietly did nothing.

could not configure AzureCli Authorizer: ... Please run 'az login' during a terragrunt plan

Or exec: "az": executable file not found in $PATH, which is the same problem on a runner without the CLI installed.

The state backend has a credential and the provider does not. They are different credentials from different chains: terragrunt-stack-env supplies the first, which is why init reads and writes state perfectly well and the failure only arrives once the plan reaches provider "azurerm". An ARM_ACCESS_KEY opens one storage account; it cannot configure a provider.

Set azure-client-id, azure-tenant-id and azure-subscription-id — the action signs in with this run's OIDC token before the first plan. Or run azure/login in an earlier step, or hand the stacks ARM_CLIENT_ID and a secret through terragrunt-stack-env. See Setup.

N stack(s) declare a provider with no credential on this runner

terragrunt-credential-preflight caught the failure above before the first plan rather than twenty stacks into it. The summary names the cloud, the stacks and the fix.

If it is wrong — the credential is there and the check cannot see it — the useful question is how the provider authenticates. A provider block that configures its own authentication is not checked at all, so a stack reading client_id from a variable is already exempt. What is left is a chain this action does not know about, and terragrunt-credential-preflight: warn is the escape hatch; off turns it off entirely. Both are worth a moment's thought first: the error it replaces costs a full plan cycle across every stack to say less.

Google STS refused the OIDC token for <provider>

The federation itself, not the permissions. The pool provider's issuer URI or its attribute condition does not match this run. Check the issuer is the one your enterprise actually mints tokens from (on GitHub Enterprise Cloud with data residency that is https://token.actions.<subdomain>.ghe.com, not token.actions.githubusercontent.com), and that the attribute condition allows this repository and ref. The message carries Google's own error_description when there is one.

the federated identity may not impersonate <service account>

The opposite half: the pool accepted the token and the service account will not be impersonated. Grant the pool's principalSet roles/iam.workloadIdentityUser on that service account. Or drop gcp-service-account entirely and bind the roles to the principalSet, which is one fewer indirection.

gcp-workload-identity-provider must be the provider's full resource name

A pool is not a provider. It is projects/<number>/locations/global/workloadIdentityPools/<pool>/providers/<provider> — the project number, not its id. Refused before any call, because Google answers a pool name with a 400 about an invalid audience that names neither the pool nor the provider.

terragrunt-credential-preflight must be auto, warn or off

It is an enum, not a boolean. true and false are refused rather than read as one of them.

role-to-assume is set but this job cannot mint an OIDC token

Add permissions: id-token: write to the job. Without it, GitHub doesn't hand the runner a token to exchange.

STS refused to assume <role-arn>. Check the role's trust policy allows this repository and ref.

The role exists and the token is valid, but the trust policy doesn't match this run. The usual cause is a sub condition scoped to a branch the run isn't on. See Setup for the policy shape, and note the StringLike on sub should carry a ref: prefix rather than repo:owner/name:*.

build-git-credentials line <n> has no ':' between the username and the token

A line is exactly <host> <username>:<token>. The username is written out rather than guessed, because the forges disagree: x-access-token for a GitHub App installation token, oauth2 for a GitLab one.

Every message from this input names the line by its index and its host and stops there. The token is never shown, and neither is the line, because the annotation carrying it is as public as the repository. Count blank lines and # comments when you go looking for line <n>: the index names the line you typed.

build-git-credentials line <n> has a token under 8 characters

Refused rather than used. A real token is never that short, so this is a truncated paste — usually a secret that resolved to nothing because it isn't set on this repository, leaving the line half-formed. Masking a string that short would be worse than not masking it: every occurrence of those few characters in every later line of the log would turn into asterisks.

build-git-credentials line <n> starts with a URL, not a host / does not start with a host

Write the bare host — git.example.invalid, not https://git.example.invalid/org/repo.git. The rewrite is built from it, and it has to match the host in the dependency URL exactly or git never applies it. Both refusals name the line by index alone: what's in that field may itself be a credential.

build-git-credentials names host <host> twice

Two rewrites for one host, and which one git picks isn't defined, so the credential in use wouldn't be the one you can read off the input. One line per host; if two builds need different tokens for the same host, they need different jobs.

GIT_CONFIG_COUNT holds '<value>', which is not a number

Something earlier in the job set GIT_CONFIG_COUNT to something git can't read. This step extends that count rather than starting again at 0, so a rewrite an earlier step configured survives; it can't do that against a value it can't parse. Fix or unset the variable.

The build still can't clone, and nothing was refused

The rewrite only fires on an exact host match. Check the host in the line is spelled the same as the host in the dependency URL, and that the token is scoped to the repository being cloned: an installation token reaches only the repositories its installation was given, and the clone fails with git's own Authentication failed rather than anything Tremvok prints.

target: github-pages

no uv.lock and no docs/requirements.txt — cannot tell how to install MkDocs

Detection looks for uv.lock first, then the file named by pages-requirements. If your repo has neither, set pages-toolchain to uv or pip explicitly.

pages-toolchain must be auto, uv or pip (got '<value>')

The only three values. auto is the default and is right almost always: uv.lock in the tree is the fact, and a caller restating it in config is one more thing that can disagree with the repo.

no mkdocs.yml in <dir> — nothing to build

working-directory doesn't point at the directory holding mkdocs.yml.

The build passed but nothing was staged for the Pages deploy

Expected on a pull request, and there's no input to change it. GitHub Pages has one site and no preview destination, so a run in mode: preview (which is what a pull request resolves to) builds and checks without staging an artifact, and dry-run: true does the same. The run summary says staging a Pages artifact: false; the log says stage-pages=false. On a push to the default branch both say true.

If a push also staged nothing, check the job summary for a skip: a fork pull request and an unwired repository both report one with a reason.

target: s3-cloudfront and target: lambda-zip

artifact-path '<path>' is not a directory / is not a file / is empty

The build step didn't produce what the deploy expects: a directory for s3-cloudfront, a .zip for lambda-zip.

The empty-directory refusal is deliberate

An empty build directory plus aws s3 sync --delete empties the live site and exits 0. The refusal is what stands between a build that quietly produced nothing and an outage. Don't add a flag to override it.

Lambda reports CodeSha256 <a> but the artifact is <b>. The function is not running the package this run built.

The update was accepted and the function is serving different code. Usually a concurrent deploy, or an update that targeted a different function or alias. "The API accepted my request" is not "the function runs my code", which is why this check exists.

No module named 'pydantic_core._pydantic_core' on the first request

A cross-architecture package. The deploy succeeded because nothing loads the code until a request arrives. build_api_zip.py --arch and the module's architecture must agree. See Configuration.

Unzipping the package on a macOS laptop and importing it fails the same way, and that one is correct: the builder fetches Linux wheels for the function's architecture.

target: cloudflare-workers

CLOUDFLARE_API_TOKEN is required / CLOUDFLARE_ACCOUNT_ID is required

The log names the input that supplies each one, cloudflare-api-token and cloudflare-account-id. Both are checked before anything is installed or built, so a missing secret costs a second rather than a build. The usual cause is a fork pull request, which can't read secrets, and which the preflight skip normally catches first.

Mint the token from Cloudflare's "Edit Cloudflare Workers" template rather than a hand-picked permission list, or the first deploy of a custom domain fails on a permission nobody thought to grant. WRANGLER_VERSION is required is the same guard: you blanked cloudflare-wrangler-version, and the tool that publishes to production is not a floating dependency.

artifact-path '<path>' is not a directory

artifact-path is the Worker's asset directory for this target, passed to Wrangler as --assets. Either the build step didn't run, or it wrote somewhere else. Leave the input empty if the asset directory in your Wrangler config is the one you want.

artifact-path '<path>' has no files in it. Refusing to publish an empty asset directory over a site that is currently serving.

The build produced nothing and the deploy would have replaced a working site with it. Same refusal as the S3 target, for the same reason.

There is no flag to override this

A build that quietly produced nothing is indistinguishable from a successful one right up until the site is empty. The refusal is the only thing standing between the two.

preview mode needs a preview-alias

mode: preview uploads a version under an alias, and the alias is what makes that version reachable without touching production. It's pr-<number>, resolved from the event, so this means preview mode with no pull request behind it: usually mode: preview forced on a push. Let mode: auto resolve it, or run mode: deploy.

You normally hit the earlier form of the same problem first, preview mode needs a pull-request number or an explicit preview-alias. The deploy step checks again anyway, because a preview uploaded under no alias is a version nobody can reach.

unsupported mode '<mode>' for target cloudflare-workers (expected deploy or preview)

mode: rollback is accepted by s3-cloudfront and lambda-zip, where it behaves exactly like a deploy: it publishes whatever artifact you hand it. Point artifact-path (or lambda-version-label) at the older build and it re-publishes that. What no target does is look up deployment history and pick the previous version for you. terragrunt, ansible and cloudflare-workers refuse the mode outright rather than pretend.

wrangler exited <n>

Wrangler's own failure, and its output is above the message in the log. The code is Wrangler's own, not tee's: pipefail is set for exactly this, so a failed publish can't report success. The two that aren't obvious from the output are a token that authenticates but lacks a permission (mint it from the "Edit Cloudflare Workers" template), and a route or custom domain already claimed by another Worker, which fails at the bind after a successful upload.

target: terragrunt

terragrunt-stack-env line <n> has a glob but no KEY=VALUE after it

Every non-blank, non-comment line is <glob> then whitespace then KEY=VALUE. A glob on its own is refused rather than skipped, because a silently dropped line means a stack runs with no credential and fails at init with something far less specific. The companion message is line <n> does not assign a KEY=VALUE, for a line whose KEY does not start with a letter or an underscore.

The line itself is never printed. This input carries credentials, and the annotation it would appear in is as public as the repository, so the message names the line by its index (counting blank and comment lines, so it matches what you typed), the glob, and the KEY — never anything at or after the first =. A line with no whitespace on it at all, ARM_ACCESS_KEY=… with the glob forgotten, is reported as ARM_ACCESS_KEY=<redacted> for the same reason.

A stack initialises against the wrong state account

Order decides it: the first matching line wins for a given key, so a * catch-all above a */prod/* pattern captures everything. Put the specific pattern first.

<n> stack(s) failed to plan

The pull-request comment carries a redacted excerpt for each stack that changed or failed, sized so the whole comment fits GitHub's limit; the workflow run has every plan in full. Nothing applies while any stack fails to plan, approval or not.

<actor> is not in terragrunt-apply-operators, so cannot force an apply

terragrunt-apply: force skips the approval, so it needs its own authorisation. Empty terragrunt-apply-operators means nobody, and the run refuses rather than applying on the strength of a flag. The normal path is an independent pull-request approval and needs no list.

The check run says Apply required before merge and stays amber

That's the intended state for a pull request with pending changes. It turns green once the stacks are applied, which is what makes apply-before-merge enforceable. Get an independent approval: approving applies the stacks.

The check run says Approved, but not applied for this commit

The pull request carries an independent approval, but this run was not started by it, so it planned and reported instead of applying. An approval applies the commit it was given for: the usual way to see this is an approval on one commit followed by a push of another, where applying the new one would apply a commit nobody reviewed.

Dismiss the approval and re-approve to apply the commit in front of you. If the workflow has no pull_request_review trigger, add one (types: [submitted, dismissed]) or nothing will ever apply. terragrunt-apply: force applies by hand for an actor named in terragrunt-apply-operators.

The same state appears when the run was triggered by a review that is a comment or a change request rather than an approval, and on a workflow_dispatch that names a pull request with terragrunt-pull-request: dispatching a workflow is not approving a commit, so that path plans and terragrunt-apply: force is its apply.

could not read the reviews of #<n>

The API call failed. The run refuses to apply rather than treating an unreadable review list as "nobody objected". Retry, or check the token has pull-requests: read. On a push to the default branch with terragrunt-apply-on-merge: true this also fails the run, after publishing the check run and the comment: nothing blocks a merge that has already happened, so a quiet skip would leave stacks unapplied with nobody told. It fails only when there were pending changes it would have applied. A merge whose stacks all plan clean reports success, because refusing to apply nothing is not a refusal.

The check run says Planned; not applied

Real changes were found and none were applied, on a run with terragrunt-apply-on-merge: true and no open pull request: a push whose commit came from no merged pull request, one merged without an independent approval, or the scheduled drift run, which never applies. neutral rather than action_required on purpose: this check lands on a commit that is already on the branch, so there is no merge left to block, and turning the default branch red is not what fixes an unapproved merge. The stacks stay unapplied and the scheduled drift run keeps reporting them. Apply them with terragrunt-apply: force and an actor named in terragrunt-apply-operators, or fix the branch protection that let the merge through.

With terragrunt-apply-on-merge off, which is the default, the same run reports Apply required before merge exactly as it always has. neutral is the better answer for a commit nothing is waiting on, but it is still a different answer, so it is gated on the input: a caller who opts into nothing keeps the conclusion they already have.

could not read the pull requests for <sha>

Only reachable with terragrunt-apply-on-merge: true. The commit-to-pull-request lookup failed, so the run cannot tell which pull request authorised the merge, which means it cannot tell whether it was approved. It fails rather than guessing, but not before the check run and every step output are published, so a required check is never left never reporting on that commit. There is no plan comment on this path: the lookup is the thing that would have said which thread to comment on. "Answered, and this commit came from no merged pull request" is a different answer and is handled differently, which is the whole point of keeping the two apart. Retry, or check the token has pull-requests: read.

Like the unreadable review list above, it fails only when there were pending changes it would have applied: a merge whose stacks all plan clean reports success and warns, because refusing to apply nothing is not a refusal. Under terragrunt-apply: never it only warns too, because that mode never consults an approval.

The log says terragrunt-apply-on-merge is off, so a push plans and applies nothing

Working as configured, and this is the default. A merge to the default branch plans the affected stacks and applies none of them, which is what this target has always done. Set terragrunt-apply-on-merge: true to have a merge that had an independent approval apply what it merged.

terragrunt-pull-request: '<value>' is not a pull-request number

Digits only. The value becomes a path segment in a GitHub API URL, so it is checked in the action's first step, before the checkout, the tofu and terragrunt download and the assume-role.

could not read pull request #<n> from <owner/repo>

terragrunt-pull-request names a pull request this run cannot see: a wrong number, or a token without pull-requests: read. The run ends there, before any discovery, init or plan, so nothing is half-applied and no state lock is taken.

the checked-out tree does not contain #<n>'s head commit <sha>

A warning, not a failure. Tremvok never checks out a merge ref: it plans whatever is on disk, and the ref: is your own actions/checkout step's business. This usually means terragrunt-pull-request was set without ref: refs/pull/<n>/merge and checkout: false, so the plan is of the default branch's code against that pull request's file list. It is a warning because checkout: false with a partial tree is a legitimate choice.

<n> of <m> preflight endpoints are unreachable from this runner

terragrunt-preflight-urls got no answer at all from those endpoints, which is DNS, connection refused, a connect timeout, a TLS handshake failure or a proxy refusal. The line above each one carries curl's own exit code, which says which: 6 DNS, 7 refused, 28 timeout, 35 or 60 TLS. The step also prints the runner's egress IP, which is what to add if the endpoint is IP-allowlisted. This runs before the first plan on purpose: terragrunt buffers plan output to a file, so the same failure without it is a silent wait until terragrunt-timeout.

A 401 or 403 is not a failure here, and an endpoint that passes has not proved your credential works. This check only asks whether anything is listening.

terragrunt-preflight-urls line <n> carries credentials in the URL

A line of the form https://user:password@host/. These URLs reach the run log, which is public on a public repository, so the run refuses rather than probing it. The message names the line by its index and shows the URL with the userinfo replaced; it never echoes the line, for the same reason it refuses it. Put the credential where it belongs and probe the bare endpoint. An unauthenticated 401 or 403 passes this check on purpose.

terragrunt-preflight-urls line <n> is not an http(s) URL

One bare URL per line, http:// or https://. A bare hostname is refused because curl would guess a scheme and quietly probe something else. The line is named by index and not printed: a value in the wrong input is the value most likely to be a secret pasted somewhere it does not go.

The log says the saved plan for <stack> has gone stale

State moved between the plan and the apply. The run re-plans and applies the newer plan rather than refusing, and says so. PLAN SOURCE: in the log names which plan actually ran. If you need the reviewed plan or nothing, re-run the whole job so plan and apply are adjacent again.

A scheduled run finds nothing

Discovery maps changed files to stacks by path, and a change under modules/ maps to nothing on purpose: a module has no state of its own. Guessing which stacks use it is how a small module tidy-up ends up planning the whole estate. terragrunt-scope: all plans everything.

The check run says No Terraform stacks affected and the change was Terraform

Check where the changed file sits. A file inside a stack maps to that stack; a file above the stacks maps to every stack beneath its own directory; a file under modules/ or outside terragrunt-root maps to nothing. Which stacks a change plans is the whole table.

Until this was fixed, only the first of those worked: a shared root.hcl has no enclosing stack, so it mapped to nothing and the run published No Terraform stacks affected as a success. The pull request merged green with every stack that includes that root unplanned. If you are pinned to a release from before the fix, that is what you are seeing, and terragrunt-scope: all is the workaround.

A shared root plans more stacks than you expected

Working as intended. Every stack beneath a shared root.hcl includes it through find_in_parent_folders, so changing it changes all of them, and a file directly in terragrunt-root reaches every stack in the estate. Move the change lower if it should not have that reach, or split the root.

target: ansible

Vault has nothing at '<path>' (404)

On a KV v2 mount the read path carries a /data/ segment that the UI path does not: secret/data/team/app, not secret/team/app. That is the cause almost every time.

Vault refused the token for '<path>' (403)

The token is valid and its policy doesn't grant read on that path. It needs read on the paths you reference and nothing else.

Vault has '<path>' but no field '<field>' in it. Fields present: ...

The reference is <path>#<field> and the field half doesn't exist. The message lists the field names that do, never their values.

cannot reach Vault at <addr> (no response)

From a private network this usually means the runner isn't on it. Check runs-on before checking the address.

ansible-ssh-private-key and ansible-ssh-private-key-vault are both set

Pick one. A literal secret and a Vault reference to the same thing is a mistake worth failing on, rather than one silently winning.

the playbook is not idempotent: a second check-mode run still wants to change <n> task(s) on <hosts>

The playbook applied cleanly and then, run again in check mode, still reported changes. That means it doesn't converge. A zero exit only proves it ran.

Usual causes: a command/shell task with no creates/changed_when, or a template that renders differently every run (a timestamp, an unsorted dict). Fix the task, or set ansible-verify-idempotence: false if you accept the gap.

the playbook applied cleanly but could not be re-run in check mode

A task with no check-mode support. Give it check_mode: false, or turn the verification off. The fix is in the playbook, not in the deploy.

no playbook at <path> / no galaxy requirements file at <path>

Paths are relative to working-directory. Both are checked before anything is installed.

The playbook cannot see VAULT_ADDR or VAULT_TOKEN

By design. The action removes them from the environment before ansible-playbook starts, so a token scoped to the fields Tremvok reads is not handed to every task, role and collection in the play. Set ansible-vault-passthrough: true to pass them through deliberately. With passthrough on and either vault-addr or vault-token missing, the run warns and passes neither: half a credential fails inside a task, a long way from the cause.

The playbook cannot see SSH_PRIVATE_KEY or VAULT_PASSWORD either

Also by design, and with no way to turn it off. Both are unset once their values are on disk in 0600 files, which is what --private-key and --vault-password-file point at. A play that wants the key or the vault password should take the file it is already given rather than read the environment, and unlike a Vault token there is nothing a playbook can do with these that the file does not already serve.

host-key checking is off for this run

A warning, not an error. You didn't supply ansible-ssh-known-hosts. It's a real downgrade, so it's said out loud rather than defaulted quietly. Supply the entries for anything reachable from a network you don't control.

The API

401 on POST /v1/deployments

authorize() is deny-by-default. The token failed one of: signature, issuer, audience, or the owner allowlist. Check TREMVOK_ALLOWED_OWNERS is set (empty denies everyone) and that api-audience on the action matches TREMVOK_OIDC_AUDIENCE on the function.

The action never fails a deploy because the record didn't land. A deploy that worked and a record that didn't is a successful deploy.

GET /healthz passes but writes fail

A health check proves the function imported. It proves nothing about the write path, the table, or the IAM policy. Test with a real signed POST. The LocalStack smoke suite exists to make that cheap.