Skip to content
Latchkey LogoLatchkey home

GitHub Actions startup_failure

A GitHub Actions startup_failure is a conclusion on the run itself, recorded before any job was created, which is why there are no job logs to open. Either the workflow file did not compile on the ref that raised the event, something it references could not be resolved, or GitHub could not prepare the run.

Diagram of where startup_failure stops the run, before jobs are created or a runner is assigned
The run is compiled before any job exists. startup_failure stops it at that point, which is why no runner change can reach the failure.

What this error means

The run appears in the Actions tab and ends almost immediately. There is no job list, no step output and no annotation pointing at a line of your code, because no job was ever created and no runner was ever asked for one. The API reports the run with a status of completed and a conclusion of startup_failure. Anything you would normally reach for is missing: there is nothing to re-run at job level, nothing to SSH into, and no runner log, because the failure happened on GitHub's side of the handover.

Workflow run, REST API
"status": "completed",
"conclusion": "startup_failure"

Why there is nothing to look at

A workflow run goes through a fixed sequence: the event is accepted, the workflow file is compiled on the ref that raised it, jobs are created, a runner is assigned, and steps run. startup_failure is the run stopping at the second step of that list, so everything to the right of it never happened.

That single fact removes most of the usual debugging. Changing runs-on, resizing the machine, moving to a different runner provider, adding a continue-on-error or retrying at job level all operate on jobs, and there are no jobs. It is also why this page carries no reproduction: the failure is not reproducible on a runner, because a runner is never involved.

It is common enough to be worth recognizing on sight. A GitHub issue search for "startup_failure" alongside "github actions" returned 4,509 results when we read it on 20 September 2026, and the pattern in those threads is consistent: a run with no logs, and a cause in the workflow file rather than in the job.

Common causes

The workflow file does not compile on the triggering ref

Invalid YAML, an unknown key, a bad expression, or a matrix that resolves to nothing. The compile happens against the ref that raised the event, which for a schedule is always the default branch and for pull_request_target is the base branch, not your feature branch.

A reusable workflow cannot be resolved

A deleted tag, a renamed path, a branch that no longer exists, or a private repository whose workflows are not shared with the caller. Resolution happens at compile time, so the failure has the shape of a file problem even though the file is fine.

The run cannot be created under the permissions it has

A missing scope in the permissions block of the calling workflow, or a rule that blocks creation. In our experience this is the cause that gets misdiagnosed as invalid YAML most often, because the file itself is well formed.

A transient error preparing the run

GitHub occasionally fails to prepare a run for reasons on its own side. This is the only cause where re-running is the right first move, and it is identified by the fact that it does not repeat.

How to fix it

Read the workflow at the ref that raised the event

  1. Identify the trigger, and from it the ref: a schedule and a manual dispatch use the default branch, a push uses the pushed ref.
  2. Open the workflow file at that ref rather than in your working copy.
  3. If the file there is older than your fix, the fix has not merged yet, and nothing else on this page applies.

Lint workflows before they merge

The compiler is the only thing that will tell you the file is wrong, and it only runs after you push. A linter in a pull request check moves that feedback earlier and costs one job.

.github/workflows/lint.yml
jobs:
  lint-workflows:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v7
      - run: |
          bash <(curl -sSfL https://raw.githubusercontent.com/rhysd/actionlint/main/scripts/download-actionlint.bash)
          ./actionlint -color

Pin reusable workflows to refs that will keep existing

Point uses: at a tag or a commit you control rather than at a moving branch, and confirm the calling repository can see the called one. A private repository has to share its workflows explicitly before another repository can call them.

.github/workflows/ci.yml
# fragile: the branch may be deleted or renamed
uses: my-org/ci-workflows/.github/workflows/build.yml@feature/new-build

# durable
uses: my-org/ci-workflows/.github/workflows/build.yml@v3

Grant the scope the run needs, one scope at a time

Start from the least access that lets the run be created rather than from write-all. If a called workflow needs to write statuses or checks, name that scope in the caller and leave the rest read-only.

.github/workflows/ci.yml
permissions:
  contents: read
  checks: write
  statuses: write

The file is compiled from the triggering ref, not your branch

This is the cause people lose the most time to. GitHub compiles the workflow from the ref that raised the event, and for several common triggers that is the default branch rather than the branch you are working on. A scheduled run always reads the file from the default branch, a pull_request_target reads it from the base branch, and a workflow_dispatch compiles the ref you dispatch, so a fix that is green in your pull request often has not taken effect yet.

The practical check is to open the workflow file at that exact ref and read it there, rather than reading the copy in your editor. A tab where a space belongs, a duplicated key, a broken expression, or a strategy block indented one level too far will all fail the compile, and the copy on your branch may already have the fix.

The second practical check is a linter, because the compiler is stricter than a YAML parser. A file can be valid YAML and an invalid workflow: an unknown key, an expression referencing a context that does not exist at that point, or a matrix that produces no combinations.

Terminal
# read the file as the event will read it
gh api repos/{owner}/{repo}/contents/.github/workflows/ci.yml?ref=main \
  --jq .content | base64 -d | head -40

# then lint it
actionlint .github/workflows/ci.yml

What the run references has to resolve too

A reusable workflow is resolved while the run is being compiled, before any job exists, so anything that stops that resolution ends the run here. A uses: pointing at a tag that was deleted, a path that was renamed, a branch that no longer exists, or a private repository that has not shared its workflows with the calling repository all land in the same place.

Permissions belong in this group as well. When the calling workflow does not hold the access the run needs at creation time, the run cannot be created, and the symptom is the same empty run. Issue threads fixing this failure frequently do it by granting a single scope in the permissions block, which is worth trying before assuming the YAML is malformed.

The same applies to required approvals and to environments: if the run cannot be constructed under the rules that apply to it, the conclusion is recorded on the run.

.github/workflows/ci.yml
jobs:
  call:
    permissions:
      contents: read
      statuses: write        # the scope the called workflow needs
    uses: my-org/ci-workflows/.github/workflows/build.yml@v3   # a ref that still exists

When the file is fine

A small share of these are GitHub-side. If the same commit produced a green run an hour earlier and nothing about the file or its references changed, re-run once and check the status page rather than editing the workflow.

The test is whether it repeats. A transient failure clears on the next attempt; a compile error does not, because the same file on the same ref compiles the same way every time. If a re-run fails identically, stop treating it as flaky and go back to the file.

How to prevent it

  • Run a workflow linter on every pull request that touches .github/workflows.
  • Pin reusable workflows to tags or commits, not to branches.
  • Keep the permissions block explicit so a missing scope is visible in review.
  • Test schedule and dispatch workflows on the default branch, since that is where they compile.

Frequently asked questions

Why does my workflow show startup_failure with no logs?
Because no job was ever created. The run failed while the workflow was being compiled, which happens before jobs exist and before a runner is assigned, so there is no job log to write. The information you have is the run conclusion and any annotation on the run itself.
Which branch is the workflow file read from?
The ref that raised the event. A push uses the pushed ref, a schedule always uses the default branch, and a pull_request_target uses the base branch. A workflow_dispatch compiles the ref you dispatch; the default branch only decides whether the workflow is offered at all. That is why a fix on a feature branch changes nothing for a scheduled run until it merges.
Can a runner problem cause startup_failure?
No. The run stops before a job exists, so no runner was ever offered the work. Changing runs-on, moving to a larger machine or switching to a different runner provider cannot affect it, and a runner that is offline or busy produces a queued job instead, which is a different failure with different symptoms.
Should I just re-run it?
Once, as a test. A genuinely transient preparation error clears on the second attempt. A compile error does not, because the same file at the same ref compiles identically every time, so a second identical failure tells you to go back to the workflow file rather than to try a third.

Related guides

References

startup_failure happens before any runner exists. Latchkey takes over from the point where one does. Start free → 30-day trial · No credit card