GitHub Actions composite action working-directory, and what reaches it
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.
- uses: ./.github/actions/build
with:
dir: app
# every run step inside build/action.yml starts at $GITHUB_WORKSPACETwo 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 |
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.
runs:
using: composite
steps:
- shell: bash
working-directory: ${{ inputs.dir }}
run: npm ciTake the directory as a declared input
- Add an input to the action's
inputsblock, with a default of.so existing callers keep working. - Use it in the
working-directoryof each run step that operates on the caller's files. - Pass it from the caller under
with, which is the only channel a uses step offers. - 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.
- 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.
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.
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 buildThe 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.
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
shelland, where it matters, an explicit directory. - When moving steps into an action, move the job defaults onto the steps in the same change.
Frequently asked questions
Why does defaults.run.working-directory not apply inside a composite action?
shell on every run step.What is a composite action step's working directory by default?
How do I run a script that ships inside my composite action?
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?
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.