# GitHub Actions composite action working-directory, and what reaches it

> A GitHub Actions composite action working-directory is joined onto the workspace, and job defaults never reach it. See the branch that decides both.

Source: https://latchkey.dev/learn/github-actions/gha-composite-working-directory-ignored  
Updated: 2026-09-20

A GitHub Actions composite action working-directory is resolved by joining whatever the step declares onto the job's workspace path, and a step that declares nothing starts at the workspace root. Job and workflow defaults are skipped entirely for steps inside an action, because the runner checks whether the step belongs to an action scope before it reads them.

## What this error means

A composite action works when its steps are pasted straight into a workflow and stops working the moment they are moved into an action. Scripts that sit next to action.yml are not found, relative paths resolve against the repository being built rather than against the action, and a `defaults.run.working-directory` that fixes everything else in the job has no effect inside the action. The error that surfaces is whatever the command says about a missing file, so nothing in the log names a directory decision.

```Illustrative caller fragment, not a log line
- uses: ./.github/actions/build
  with:
    dir: app
# every run step inside build/action.yml starts at $GITHUB_WORKSPACE
```

## Common causes

### The step relies on a job default that never reaches it

The job sets `defaults.run.working-directory`, every step written directly in the workflow honors it, and the composite action's steps do not. The condition that reads job defaults requires an empty scope name, and a step inside an action has one.

### A relative path was used to reach the action's own files

Paths such as `./scripts/setup.sh` inside a composite action resolve against the workspace, not against the directory holding action.yml. The action works while it lives inside the repository it is used from and breaks the moment it is published and downloaded to the runner's action cache.

### The directory was set on the caller's uses step

The caller tried to push the directory in. The schema has no such property on a uses step, so instead of running in the wrong place, nothing runs at all and the workflow file is annotated.

### The action was refactored out of a workflow that had a default

In our experience this is how it usually arrives. The steps worked as workflow steps under a job-level default, were moved into an action unchanged, and lost the default silently because nothing in the move mentions directories.

## How to fix it

### Set working-directory on every composite run step that needs it

There is no inherited value to rely on, so each run step inside the action states its own directory or accepts the workspace root. Being explicit also documents the action for the next reader, who otherwise has to know the scope rule to predict the behavior.

```.github/actions/build/action.yml (illustrative)
runs:
  using: composite
  steps:
    - shell: bash
      working-directory: ${{ inputs.dir }}
      run: npm ci
```

### Take the directory as a declared input

1. Add an input to the action's `inputs` block, with a default of `.` so existing callers keep working.
2. Use it in the `working-directory` of each run step that operates on the caller's files.
3. Pass it from the caller under `with`, which is the only channel a uses step offers.
4. Document in the action's README that paths are relative to the workspace, because that is what the runner joins against.

### Address the action's own files with github.action_path

Use the absolute path the runner provides whenever a step needs something that ships with the action. It survives the workspace join because a rooted path wins, and it is correct for both a local action and a published one, which a relative path cannot be.

```.github/actions/build/action.yml (illustrative)
- shell: bash
  run: bash "${{ github.action_path }}/scripts/setup.sh"
```

### Give each composite run step its shell as well

The same scope check that skips the directory default also skips the shell default, which is why the composite action schema marks `shell` required on a run step while the workflow schema does not. If you are fixing directories in an action, the shell keys are the other half of the same change.

## How to prevent it

- Treat a composite action as having no access to the caller's defaults, because it has none.
- Reach the action's own files only through `github.action_path`.
- Give every composite run step an explicit `shell` and, where it matters, an explicit directory.
- When moving steps into an action, move the job defaults onto the steps in the same change.

## Two decisions, both in the same method

When the runner prepares a script step it does two things with the directory, one after the other. First it looks for a directory on the step itself. If the step does not declare one, it will consider the job's run defaults, but only inside a condition that requires the step's scope name to be empty. A step that came from an action has a scope name, so that condition is false and the defaults are not read.

Second, whatever it ended up with, including nothing at all, is joined onto the workspace path taken from the github context. That join is the whole of the path resolution. There is no fallback to the action's own directory, and no awareness that the step came from an action at any point in the calculation.

The same scope check guards the shell default, which is why the composite action schema makes `shell` required on every run step inside an action while the workflow schema does not. The two facts have one cause: defaults declared in a workflow are for that workflow's own steps.

| Setting | Reaches a composite run step | Resolved against |
| --- | --- | --- |
| `working-directory` on the composite step | yes | the workspace path |
| `working-directory` on the caller's `uses` step | no, the file is refused | nothing |
| `defaults.run.working-directory` on the job | no, the scope check skips it | nothing |
| An absolute path such as `${{ github.action_path }}` | yes | itself, the join keeps it |

> The fourth row is the one to remember. Joining a rooted path onto another path yields the rooted path, so an absolute value passes through the workspace join untouched. That is what makes `github.action_path` usable here.

## Why an absolute path is the only way to reach the action's own files

A composite action's files live wherever the runner put them, which for a published action is a directory under the runner's action cache and for a local action is inside the checked out repository. Neither location is a fixed offset from the workspace, so no relative `working-directory` can name it reliably, and a value that happens to work for a local action breaks the day the action is published.

The runner exposes the answer as `github.action_path`, which is absolute. Because the resolution is a join and a join preserves a rooted path, setting `working-directory: ${{ github.action_path }}` lands the step in the action's directory rather than in the repository being built. The same expression works in a command, which is often tidier: call the script by its absolute path and leave the working directory alone, so the action still operates on the caller's files.

Decide which of those two you want before you write either. An action that builds the caller's project wants the workspace as its working directory and its own scripts addressed absolutely. An action that runs a tool shipped inside itself wants the opposite.

```.github/actions/build/action.yml (illustrative)
runs:
  using: composite
  steps:
    - name: Run a script that ships with the action
      shell: bash
      run: "${{ github.action_path }}/scripts/setup.sh"

    - name: Build the caller's project
      shell: bash
      working-directory: ${{ inputs.dir }}
      run: npm ci && npm run build
```

## The caller cannot push a directory in, and the schema says so

The instinct when an action starts in the wrong place is to set the directory on the calling step. That does not work, and it does not fail quietly either: the workflow schema gives a step that carries `uses` no `working-directory` property, so the file is rejected and no run is created. The sibling page in this batch traces that rejection to the branch that raises it and explains how to read the annotation, so it is not repeated here.

The mechanism a caller does have is an input. Declare one in the action's `inputs` block, pass it with `with`, and use it in the composite step's own `working-directory`. That makes the directory part of the action's contract, visible to anyone reading action.yml, rather than a property the caller hopes leaks through.

Note the direction of travel this implies. An action that wants to be usable from a subdirectory has to say so in its interface. An action that does not declare a path input has decided, whether the author meant to or not, that it works at the workspace root.

## Why there is no recorded run on this page

What decides this page is which of two branches the runner takes inside one method, and a branch is not printed. A recorded run would show a command failing to find a file, and the same line appears whether the step was in an action scope, whether a default was skipped, or whether the path was simply wrong. The log cannot separate the three, which is the entire question the page exists to answer.

The join and the scope check are both readable in the runner's script handler, and the required `shell` key in the composite action schema corroborates the scope check from a second direction. That is stronger evidence than a log of a failure with several possible causes.

## FAQ

### Why does defaults.run.working-directory not apply inside a composite action?

Because the runner only reads job run defaults when the step has no action scope name. A step that came from a composite action has one, so the condition is false and the defaults are skipped. The same check skips the shell default, which is why the composite action schema requires `shell` on every run step.

### What is a composite action step's working directory by default?

The job's workspace. The runner joins whatever the step declared, or an empty string if it declared nothing, onto the workspace path from the github context. There is no step in that calculation that looks at where the action itself was downloaded.

### How do I run a script that ships inside my composite action?

Address it through `github.action_path`, which is absolute. Either call the script by its absolute path or set `working-directory` to that value, because joining an absolute path onto the workspace yields the absolute path unchanged. A relative path will resolve against the caller's checkout instead.

### Can the calling workflow set working-directory on the uses step?

No. A step that carries `uses` has no `working-directory` property in the schema, so the workflow file is rejected and no run is created. Pass a path as an input the action declares instead.

## References

- [actions/runner: ScriptHandler.cs, the scope check and the workspace join](https://github.com/actions/runner/blob/main/src/Runner.Worker/Handlers/ScriptHandler.cs)
- [actions/runner: action_yaml.json, the composite run step definition](https://github.com/actions/runner/blob/main/src/Runner.Worker/action_yaml.json)
- [GitHub Actions: metadata syntax for composite actions](https://docs.github.com/en/actions/reference/workflows-and-actions/metadata-syntax)
- [GitHub Actions: contexts reference, the github context](https://docs.github.com/en/actions/reference/workflows-and-actions/contexts)

---

Latchkey runs CI/CD that repairs its own failures. Agent entry points: https://latchkey.dev/agent.txt, https://latchkey.dev/openapi.json, https://latchkey.dev/llms.txt
