Skip to content

GitHub Actions Steps: run, uses, and Everything Between

Lesson 6 of 11Beginner10 min readGitHub Actions & CI/CD · Actions FundamentalsVerified: GitHub Actions workflow syntax documentation, August 2026

A step is the smallest unit of work in a workflow, and it comes in exactly two kinds.

steps:
- run: make test # a shell step
- uses: actions/checkout@v7 # an action step

run: executes a command on the runner, in a shell.

uses: executes an action — reusable code from a repository, a directory, or a container image.

A step has one or the other, never both. Everything else configures which one you chose.

Unlike jobs, steps within a job share everything: the machine, the filesystem, the working directory, and any files a previous step created.

steps:
- uses: actions/checkout@v7
- run: npm ci # creates node_modules/
- run: npm test # can see node_modules/

That is why order matters within a job and why actions/checkout goes first — everything after it depends on the repository existing.

What steps do not share automatically is environment variables set by a command:

- run: export VERSION=1.4.0
- run: echo "$VERSION" # empty

Each run: step is a separate shell process. export affects that process and dies with it. Passing a value forward needs $GITHUB_ENV, covered in Environment Variables.

- name: Install dependencies
id: install
run: npm ci

name is the label in the log. Without it, GitHub shows the command itself, which is fine for npm ci and unreadable for a twenty-line script.

id lets other steps reference this one — for its outputs or its outcome:

- id: version
run: echo "value=1.4.0" >> "$GITHUB_OUTPUT"
- run: echo "Version is ${{ steps.version.outputs.value }}"

Only steps you need to reference need an id. Naming every step is worth it; giving every step an id is clutter.

An action’s inputs are passed with with:

- uses: actions/setup-python@v7
with:
python-version: "3.13"
cache: pip
- uses: actions/upload-artifact@v7
with:
name: coverage
path: htmlcov/
retention-days: 7

Each action defines its own inputs in its action.yml. There is no shared vocabulary — path means different things to different actions — so reading the action’s documentation is not optional.

with applies only to uses: steps. On a run: step it is invalid.

run: executes in a shell, and which one depends on the runner.

RunnerDefault shell
Linuxbash
macOSbash
WindowsPowerShell

Being explicit removes the difference:

- shell: bash
run: echo "$HOME"
- shell: pwsh
run: Write-Output $env:HOME
- shell: python
run: |
import platform
print(platform.platform())

bash, pwsh, python, sh, cmd and powershell are available, plus a custom form for anything else.

GitHub runs bash steps with --noprofile --norc -eo pipefail. That means no profile scripts, exit on first error, and pipeline failure propagation — a stricter environment than an interactive shell, and the reason a command that works locally can fail on a runner.

Setting it for a whole job or workflow avoids repetition:

defaults:
run:
shell: bash
working-directory: ./app
- run: npm ci
working-directory: ./frontend

Applies to that step only. For a monorepo where everything happens in one subdirectory, set it in defaults instead.

working-directory has no effect on uses: steps — an action decides its own paths, usually through an input.

if: decides whether a step runs.

- name: Publish
if: github.ref == 'refs/heads/main'
run: ./publish.sh
- name: Report coverage
if: always()
run: ./coverage-report.sh
- name: Handle failure
if: failure()
run: ./on-failure.sh

By default a step runs only if all previous steps succeeded. Once a step fails, the rest are skipped — which is usually right and is exactly wrong for cleanup.

The status functions override that:

FunctionRuns when
success()Everything so far succeeded (the default)
failure()Something failed
cancelled()The workflow was cancelled
always()Regardless — including cancellation

always() is the blunt instrument. !cancelled() is usually better for cleanup, because it runs on success and failure but respects an explicit cancellation.

Conditions can combine with any expression:

- if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags/')
run: ./release.sh

By default a failing step fails its job. Sometimes that is wrong:

- name: Optional lint
continue-on-error: true
run: ./strict-lint.sh
- name: Always runs next
run: echo "reached"

The step’s failure is recorded — the run shows it — but the job continues and can still succeed.

Combined with an id, you can react to it:

- id: flaky
continue-on-error: true
run: ./sometimes-fails.sh
- if: steps.flaky.outcome == 'failure'
run: echo "::warning::flaky step failed; continuing"
- run: ./deploy.sh
env:
ENVIRONMENT: staging
LOG_LEVEL: debug

Applies to that step only, and overrides job- and workflow-level values of the same name.

This is also the safe way to pass untrusted data into a shell command:

# Unsafe — the title is substituted as script
- run: echo "Title: ${{ github.event.pull_request.title }}"
# Safe — the title is data in a variable
- env:
TITLE: ${{ github.event.pull_request.title }}
run: echo "Title: $TITLE"

The reason is substitution order: ${{ }} is replaced before the shell parses the line, so shell metacharacters in the value become shell syntax. Through env, the value is assigned to a variable and never parsed as code. See Workflow Security.

- run: ./long-running.sh
timeout-minutes: 10

Useful on a step that can hang independently of the job as a whole — a network call, a wait loop. The job’s own timeout-minutes remains the outer bound.

The default behaviour is worth stating precisely, because it governs how a job unwinds.

Steps run in order. When one fails:

  • Remaining steps with no if: are skipped.
  • Steps with if: always(), if: failure() or if: !cancelled() still run.
  • The job’s conclusion is failure.
  • Post-run steps registered by actions still execute — cleanup happens regardless.

That last point explains why actions/cache still saves, and why actions/checkout still removes its credentials, on a failed job. Action cleanup is not conditional on your steps succeeding.

A practical consequence: a step that creates external state — starting a container, provisioning something — should have a matching cleanup step guarded with if: always(), because the default behaviour will skip it exactly when it is needed.

- name: Start dependencies
run: docker compose up -d
- name: Test
run: make test
- name: Stop dependencies
if: always()
run: docker compose down -v

The conditions that come up repeatedly:

# Default branch only
- if: github.ref == 'refs/heads/main'
# Tags only
- if: startsWith(github.ref, 'refs/tags/')
# Not on a fork's pull request
- if: github.event.pull_request.head.repo.full_name == github.repository
# Only when a previous step found something
- if: steps.detect.outputs.changed == 'true'
# Only on one matrix variant — useful for publishing once
- if: matrix.os == 'ubuntu-latest' && matrix.version == '3.13'
# Not for bot-authored pull requests
- if: github.event.pull_request.user.type != 'Bot'

The fork check is the important one for security. Comparing the head repository against the current one distinguishes an internal branch from an external contribution, which is how a step that needs credentials avoids running where credentials are unavailable — and where running it would be unsafe even if they were.

The matrix variant condition solves a recurring problem: a matrix job that should upload a coverage report or publish an artifact once rather than six times.

Two mechanisms for a step that may not finish.

- name: Wait for deployment
timeout-minutes: 10
run: ./wait-for-healthy.sh

timeout-minutes bounds it. Without one, a hung step consumes the job’s timeout, which defaults to the platform maximum.

For work that should proceed in the background while other steps run, backgrounding is possible and rarely the right answer — a job’s steps are sequential by design, and parallel work usually belongs in a separate job.

The exception is starting a service the later steps depend on, and even then a service container with a health check is more reliable than backgrounding a process and sleeping.

Both run and uses on one step. Invalid; a step is one or the other.

Expecting export to persist. Each run step is a new shell; use $GITHUB_ENV.

No name on a long script. The log shows the whole command as its label.

Cleanup without if: always(). Skipped exactly when needed.

Checking conclusion where you meant outcome. With continue-on-error, they differ.

Interpolating untrusted input into run:. Pass it through env:.

working-directory on a uses: step. No effect.

A step frequently produces something later jobs need, and the upload should survive failure:

- name: Run tests
run: pytest --junitxml=results.xml --cov --cov-report=html
- name: Upload results
if: always()
uses: actions/upload-artifact@v7
with:
name: test-results-${{ matrix.python-version }}
path: |
results.xml
htmlcov/
retention-days: 14

if: always() is the important line. Test results matter most when tests fail, and without it the upload is skipped precisely then — leaving you with a red run and no report.

The artifact name includes the matrix value, because every variant uploads and names must be unique within a run.

A worked example bringing the lesson together:

jobs:
test:
runs-on: ubuntu-latest
timeout-minutes: 20
steps:
- name: Check out
uses: actions/checkout@v7
- name: Set up Python
uses: actions/setup-python@v7
with:
python-version: "3.13"
cache: pip
- name: Install
run: pip install -r requirements.txt -r requirements-dev.txt
- name: Lint
id: lint
continue-on-error: true
run: ruff check --output-format=github .
- name: Test
run: pytest --junitxml=results.xml
- name: Note lint failure
if: steps.lint.outcome == 'failure'
run: echo "::warning::lint reported issues; see the annotations above"
- name: Upload results
if: always()
uses: actions/upload-artifact@v7
with:
name: results
path: results.xml
- name: Summarise
if: always()
run: |
{
echo "## Result: ${{ job.status }}"
echo "- Lint: ${{ steps.lint.outcome }}"
} >> "$GITHUB_STEP_SUMMARY"

Six ideas in one job: an action step with inputs, a named shell step, continue-on-error with a follow-up condition reading outcome, an always() upload, --output-format=github producing annotations directly, and a summary that renders on the run page.

Note that ruff check --output-format=github emits GitHub’s annotation format natively. Many linters have an equivalent flag, and using it is much better than parsing output yourself — the failures appear on the pull request’s Files changed tab next to the code.

  1. Write a job where one step exports a variable and the next tries to read it. Confirm it is empty.
  2. Add a failing step followed by a normal step; confirm the second is skipped.
  3. Add if: always() to the second and confirm it now runs.
  4. Add continue-on-error: true to the failing step and compare outcome and conclusion in a later condition.
  5. Interpolate github.event.pull_request.title directly into a run: step, then rewrite it using env:, and compare what the log shows for a title containing a quote.
  6. Set shell: python on a step and confirm it runs Python.

Step 5 is worth doing on a pull request whose title contains " — it demonstrates the injection surface without needing anything malicious.

There are none — permissions is a workflow or job key only. A step cannot narrow what the token can do.

That has a design consequence: if one step in a job needs write access and the rest do not, the whole job has write access for its entire duration, including while third-party actions run.

The way to scope tightly is therefore to split into jobs:

jobs:
build:
permissions:
contents: read
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- run: npm ci && npm run build # third-party code, read-only token
publish:
needs: build
permissions:
contents: write
packages: write
runs-on: ubuntu-latest
steps:
- run: ./publish.sh # elevated, minimal surface

The build job runs the dependency install — the step most likely to execute someone else’s code — with a read-only token. The publish job has write access and runs only code you control.

That split costs a job and an artifact hand-off, and it is one of the highest-value structural decisions available for a pipeline that publishes anything.

  • A step is run: or uses:, never both.
  • Steps share a runner and filesystem; they do not share shell variables.
  • with configures an action; it is invalid on a run: step.
  • The default shell differs by runner — shell: bash makes behaviour uniform.
  • Steps run only after success by default; always() and !cancelled() override that.
  • outcome is before continue-on-error, conclusion is after.
  • Passing untrusted values through env: rather than interpolating them removes the injection surface.

A step can emit specially formatted lines that GitHub interprets rather than merely logging.

- run: |
echo "::error file=src/app.py,line=42::Undefined variable"
echo "::warning::Dependency is two majors behind"
echo "::notice::Build finished in 42 seconds"
echo "::group::Installing dependencies"
npm ci
echo "::endgroup::"
echo "::add-mask::$DERIVED_TOKEN"

Annotationserror, warning, notice — appear on the run summary, and with file and line they attach to the pull request’s Files changed tab next to the code. That is where a reviewer already is, which makes an annotation far more useful than the same text in a log.

::group:: creates a collapsible section. A long dependency install collapsed to one line makes the rest of a log navigable.

::add-mask:: registers a value for masking at runtime. Useful when a step computes something sensitive — a derived token, a signed URL — that GitHub does not already know to mask. It is one of the few ways to extend masking beyond configured secrets.

$GITHUB_STEP_SUMMARY accepts Markdown and renders on the run’s summary page:

- name: Report coverage
run: |
{
echo "## Coverage"
echo ""
echo "| Module | Coverage |"
echo "| --- | ---: |"
echo "| core | 94% |"
echo "| api | 81% |"
} >> "$GITHUB_STEP_SUMMARY"

For anything a reviewer needs to see — coverage, benchmark deltas, what was deployed — this is markedly better than log output. Steps append in order, so several can contribute.

There is no built-in retry. The honest first response to a flaky step is to fix the flakiness, because retrying hides a real signal. Where the flakiness is genuinely external — a registry that intermittently times out — a loop is the mechanism:

- name: Install with retry
run: |
for attempt in 1 2 3; do
if npm ci; then exit 0; fi
echo "::warning::npm ci failed (attempt $attempt)"
sleep $((attempt * 5))
done
echo "::error::npm ci failed after 3 attempts"
exit 1

The warning annotation on each failure matters: without it, a step that succeeded on the third attempt looks identical to one that succeeded immediately, and the flakiness never gets measured.

A step can run in a container even when its job does not:

- uses: docker://alpine:3.20
with:
args: echo "hello from a container"

The docker:// form runs an image directly as an action. It is occasionally useful for a tool you do not want to install on the runner, and it pays container startup on every use — so for anything called repeatedly, installing the tool is usually faster.

GitHub Actions Security ChecklistAudit your workflows against the failure modes that actually cause incidents. Free and complete.

The CI starter template — least-privilege permissions, pinned actions, correct checkout — is in the Professional Toolkit.