skip to content

Jobs & Steps

Job anatomy: the needs graph that decides what runs in parallel, runs-on, steps that either use an action or run a script, job outputs, and conditionals like failure() or always(). Asked because the dependency graph is where pipeline duration is won or lost, and a cleanup step that skips on failure is a classic bug.

part ofGitHub Actionsoverview, primer and where to startread it →
on this pageshow

questions

6

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

open as a page

How does one GitHub Actions job pass a computed value to a later job?

level: middleimportance: must knowfreq 66%

basics

~10 s

A step appends name=value to the file at $GITHUB_OUTPUT and carries an id. The job republishes it under its outputs: map, and any job that lists it in needs: reads it as needs.<job_id>.outputs.<name>.

open as a page

In a GitHub Actions workflow, what does a job's needs: key change about execution?

level: middleimportance: must knowfreq 80%

basics

~20 s

Jobs in a GitHub Actions workflow run in parallel by default. Adding needs: makes a job wait for the listed jobs and, by default, skip entirely if any of them fails, turning the job list into a dependency graph.

open as a page

What does timeout-minutes do in a GitHub Actions job, and what is its default?

level: middleimportance: should knowfreq 45%

basics

~20 s

timeout-minutes caps how long a GitHub Actions job may run before the runner terminates it and the job is reported as failed. The default is 360 minutes, and the key can also be set on an individual step.

open as a page

In GitHub Actions, why is a final cleanup step skipped when an earlier step fails?

level: seniorimportance: should knowfreq 62%

basics

~20 s

Every GitHub Actions step carries an implicit condition that all previous steps succeeded, so one failure skips the rest of the job. A cleanup step must opt out with an explicit if:, such as if: always() or if: !cancelled().

open as a page

When would you split a GitHub Actions pipeline into more parallel jobs rather than more steps?

level: principalimportance: should knowfreq 42%

basics

~20 s

Split when the work is genuinely independent and long enough to repay a new runner, or when a stage needs its own permissions or re-run granularity. Keep work in one job when steps share a large workspace, because separate jobs share no filesystem.

open as a page