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 steprun: 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.
Steps share a runner
Section titled “Steps share a runner”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" # emptyEach 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.
Naming and identifying
Section titled “Naming and identifying”- name: Install dependencies id: install run: npm ciname 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.
Action steps and with
Section titled “Action steps and with”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: 7Each 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.
The shell
Section titled “The shell”run: executes in a shell, and which one depends on the runner.
| Runner | Default shell |
|---|---|
| Linux | bash |
| macOS | bash |
| Windows | PowerShell |
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: ./appWorking directory
Section titled “Working directory”- run: npm ci working-directory: ./frontendApplies 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.
Conditions
Section titled “Conditions”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.shBy 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:
| Function | Runs 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.shcontinue-on-error
Section titled “continue-on-error”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"Step-level environment
Section titled “Step-level environment”- run: ./deploy.sh env: ENVIRONMENT: staging LOG_LEVEL: debugApplies 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.
Step timeouts
Section titled “Step timeouts”- run: ./long-running.sh timeout-minutes: 10Useful 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.
Step ordering and failure
Section titled “Step ordering and failure”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()orif: !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 -vConditional patterns
Section titled “Conditional patterns”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.
Long-running steps
Section titled “Long-running steps”Two mechanisms for a step that may not finish.
- name: Wait for deployment timeout-minutes: 10 run: ./wait-for-healthy.shtimeout-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.
Common mistakes
Section titled “Common mistakes”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.
Steps that produce artifacts
Section titled “Steps that produce artifacts”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: 14if: 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.
Composing steps into a job
Section titled “Composing steps into a job”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.
Exercise
Section titled “Exercise”- Write a job where one step exports a variable and the next tries to read it. Confirm it is empty.
- Add a failing step followed by a normal step; confirm the second is skipped.
- Add
if: always()to the second and confirm it now runs. - Add
continue-on-error: trueto the failing step and compareoutcomeandconclusionin a later condition. - Interpolate
github.event.pull_request.titledirectly into arun:step, then rewrite it usingenv:, and compare what the log shows for a title containing a quote. - Set
shell: pythonon 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.
Step-level permissions
Section titled “Step-level permissions”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 surfaceThe 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.
What you learned
Section titled “What you learned”- A step is
run:oruses:, never both. - Steps share a runner and filesystem; they do not share shell variables.
withconfigures an action; it is invalid on arun:step.- The default shell differs by runner —
shell: bashmakes behaviour uniform. - Steps run only after success by default;
always()and!cancelled()override that. outcomeis beforecontinue-on-error,conclusionis after.- Passing untrusted values through
env:rather than interpolating them removes the injection surface.
Workflow commands
Section titled “Workflow commands”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"Annotations — error, 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.
Step summaries
Section titled “Step summaries”$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.
Retrying a flaky step
Section titled “Retrying a flaky step”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 1The 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.
Steps that run in a container
Section titled “Steps that run in a container”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.