skip to content

In a GitHub Actions workflow step, what is the difference between uses: and run:?

level: juniorimportance: must knowfreq 78%

answer

  1. two mutually exclusive step keys
  2. one invokes packaged code
  3. the other executes commands
  4. with: belongs to only one of them
  5. each run: gets a fresh shell

basics

~20 s

A step either invokes packaged code with uses:, which points at an action by repository, local path, or Docker image and takes inputs via with:, or executes shell commands with run:. One step cannot have both keys.

solid answer

~40 s

Every step inside a GitHub Actions job is exactly one of two kinds. A `uses:` step invokes a packaged **action** — `actions/checkout@v4`, `./.github/actions/setup`, or `docker://alpine:3.20` — and passes parameters through `with:`, whose names are defined by that action's `action.yml`. A `run:` step executes shell commands on the runner, one shell process per step, in `github.workspace` by default; a non-zero exit code fails the step. The two keys are mutually exclusive: a step containing both is rejected when GitHub parses the workflow. Keys such as `name`, `id`, `if`, `env`, `continue-on-error` and `timeout-minutes` are valid on both kinds, while `with:` is only meaningful on `uses:` and `shell:`/`working-directory:` only on `run:`. Because each `run:` step is a fresh shell, a `cd` or an exported variable does not survive into the next step.

code

yaml · 20 lines
yaml
jobs:
  build:
    runs-on: ubuntu-latest
    defaults:
      run:
        shell: bash
        working-directory: backend
    steps:
      - uses: actions/checkout@v4
        with:
          fetch-depth: 0
      - name: Build
        id: build
        run: |
          ./gradlew --no-daemon build
          echo "artifact=build/libs/app.jar" >> "$GITHUB_OUTPUT"
      - uses: actions/upload-artifact@v4
        with:
          name: app-jar
          path: backend/${{ steps.build.outputs.artifact }}

go deeper

for a junior

Recall that a step either calls an action with uses: or runs commands with run:, never both, and that with: only accompanies uses:. Be able to write a two-step job that checks out the repository and then runs a build command.

for a middle

Explain that each run: step is its own shell process, so directory changes and exports do not persist, and that $GITHUB_ENV and $GITHUB_PATH are the supported way to carry values forward. Know where defaults.run can be declared.

for a senior

Show judgment about when to inline a script versus depend on a third-party action, and mention the trust boundary that a uses: reference creates along with SHA pinning. Be ready to explain pipefail behaviour biting a build that silently passed.

for a principal

Own the standard: which steps are allowed to be ad hoc shell, which must go through a reviewed internal composite action, and how defaults and shared env keep dozens of repositories consistent without copy-pasted YAML.

## The two shapes of a step A GitHub Actions job carries an ordered `steps:` list. Every entry in that list runs on the same runner, in the same checkout directory, in the order written — and every entry is one of exactly two shapes: - **`uses:`** — invoke somebody's packaged code (an *action*). - **`run:`** — execute shell commands yourself. They are mutually exclusive within a single step. A step with both keys is not "action first, script second" — GitHub rejects the workflow file when it parses it, and the run never starts. If you need both behaviours, write two steps. ## `uses:` — invoking an action `uses:` takes a reference to a unit of packaged, reusable code: - `owner/repo@ref` — the common case, e.g. `actions/checkout@v4`. The `ref` may be a tag, a branch, or a full commit SHA. - `owner/repo/subdirectory@ref` — an action stored in a subfolder of a repository. - `./path/to/action` — an action living in the *same* repository, resolved relative to the workspace, so a checkout must have happened first. - `docker://image:tag` — a container image run directly as the step. Parameters go in `with:`, and their names are not free-form: the action's `action.yml` declares each input, whether it is required, and its default. Passing a name the action does not declare is silently ignored rather than helpful, which is why a typo in `with:` usually shows up as "the action did nothing". What the action *does* — checkout, cache restore, cloud login — is opaque to your workflow; it just runs and may expose step outputs. ## `run:` — executing commands `run:` takes a command string, or a multi-line block via YAML's `|`: ``` - run: | ./gradlew build ls build/libs ``` Things worth internalising about `run:`: - **One shell process per step.** State that lives only in the shell — the current directory, an exported variable, a shell function — is gone when the step ends. To carry a variable forward, append `NAME=value` to the file named by `$GITHUB_ENV`; to add a directory to `PATH` for later steps, append it to `$GITHUB_PATH`. - **Working directory** defaults to the workspace (`github.workspace`). Override per step with `working-directory:`. - **Shell** defaults to `bash` on Linux and macOS runners and `pwsh` on Windows runners; set it explicitly with `shell:`. Setting `shell: bash` explicitly is not identical to the default — the explicit form runs with `-o pipefail`, so a failing command in the middle of a pipe fails the step. - **Exit code is the verdict.** Non-zero fails the step, and by default every later step in the job is skipped. ## Keys that apply to both `name`, `id`, `if`, `env`, `continue-on-error` and `timeout-minutes` are valid on either shape. `id` matters most: it is how later steps reference this step's outputs through the `steps` context. ## Cutting repetition with defaults and env Rather than repeating `shell:` and `working-directory:` on every command step, set them once: ``` defaults: run: shell: bash working-directory: backend ``` This block is legal at workflow level and at job level, and it affects `run:` steps only — an action invoked with `uses:` decides its own working directory. Similarly, an `env:` map may sit at workflow, job, or step level, with the innermost definition winning. ## Choosing between them Reach for `run:` when the logic is specific to this repository and short: invoking your build tool, moving a file, printing a diagnostic. Reach for `uses:` when the work is generic and someone has already solved the cross-platform and caching details — checking out the repository, setting up a language toolchain, restoring a cache, uploading an artifact. The cost of `uses:` is trust: a third-party action executes with access to the job's workspace and whatever secrets you hand it, which is why teams pin such references to a full commit SHA rather than a moving tag. The cost of `run:` is that you own the portability and the error handling yourself.

  • How do you change the shell that a run: step uses in GitHub Actions?
    Set `shell:` on the step, or set it once for the job or workflow under `defaults.run.shell`. The implicit default is bash on Linux and macOS runners and pwsh on Windows. Note that writing `shell: bash` explicitly is not a no-op: the explicit form adds `-o pipefail`, so a command failing mid-pipeline fails the step instead of being masked by a successful final command.
  • Why does an exported variable in one run: step not appear in the next one?
    Each `run:` step is a separate shell process, so its environment dies with it. To pass a value forward, append `NAME=value` to the file path in `$GITHUB_ENV`; later steps in the same job see it as an environment variable. To extend `PATH`, append the directory to `$GITHUB_PATH`. For a value another *job* needs, use step outputs plus job outputs instead.
  • What is the difference between with: and env: on a step?
    `with:` supplies the declared inputs of the action named in `uses:`, and is only meaningful on such a step; the input names must match the action's `action.yml`. `env:` sets environment variables for the step's process, which works for either shape and is how a `run:` script receives values. Passing an undeclared name under `with:` has no effect.

saying these in an interview costs you the question

  • Claims a step can use uses: and run: together
  • Thinks a cd in one run step persists to the next
  • Uses with: to pass variables into a run: script
  • Assumes run: always executes in bash on every runner
  • Believes with: keys are free-form rather than declared by the action

context