Skip to content
Latchkey LogoLatchkey home

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.

Two paths through the runner script handler, one inside an action scope and one outside
The handler reads job defaults only when the step has no action scope. Inside a composite action the scope is set, so the defaults branch is never taken.

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

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.

SettingReaches a composite run stepResolved against
working-directory on the composite stepyesthe workspace path
working-directory on the caller's uses stepno, the file is refusednothing
defaults.run.working-directory on the jobno, the scope check skips itnothing
An absolute path such as ${{ github.action_path }}yesitself, 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.

.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.

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.

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.

Frequently asked questions

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.

Related guides

References

An action you reuse everywhere runs everywhere. Latchkey runs it at $0.0025/min at 2 vCPU. Start free → 30-day trial · No credit card