What is a workflow?
A workflow is a text file describing a sequence of automated actions. When an event happens (a push, a pull request, a scheduled time), GitHub reads that file and runs the instructions it holds.
Concretely, a workflow answers four questions:
| Question | YAML section | Example |
|---|---|---|
| WHEN does it run? | on: | On every push, on PRs, at midnight |
| WHERE does it run? | runs-on: | Ubuntu, Windows, macOS |
| WHAT does it do? | steps: | Test, build, deploy |
| UNDER WHICH CONDITIONS? | if: | On main only, if the tests pass |
Anatomy of a workflow
Let us look at a realistic workflow and take it apart:
# ══════════════════════════════════════════════════════════════════════════════# METADATA# ══════════════════════════════════════════════════════════════════════════════name: CI # The name shown in the GitHub interfacerun-name: Tests for ${{ github.ref_name }} # A dynamic run name
# ══════════════════════════════════════════════════════════════════════════════# WHEN? (Triggering events)# ══════════════════════════════════════════════════════════════════════════════on: push: branches: [main, develop] # On pushes to these branches pull_request: branches: [main] # On PRs targeting main workflow_dispatch: # A manual button in the interface
# ══════════════════════════════════════════════════════════════════════════════# SECURITY (Permissions)# ══════════════════════════════════════════════════════════════════════════════permissions: contents: read # Reading the code (the minimum needed)
# ══════════════════════════════════════════════════════════════════════════════# GLOBAL CONFIGURATION# ══════════════════════════════════════════════════════════════════════════════env: NODE_VERSION: '20' # Usable in every job
# ══════════════════════════════════════════════════════════════════════════════# THE JOBS (The units of work)# ══════════════════════════════════════════════════════════════════════════════jobs: # Job 1: tests test: runs-on: ubuntu-24.04 steps: # Actions pinned by SHA (see the box below) - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 with: node-version: ${{ env.NODE_VERSION }} - run: npm ci - run: npm test
# Job 2: build (waits for test to pass) build: needs: test # An explicit dependency runs-on: ubuntu-24.04 steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - run: npm ci - run: npm run buildThe hierarchy: workflow, then jobs, then steps
This is the most important structure to understand. If you remember one thing from this guide, make it this one.
The fundamental rules
| Concept | Environment | Execution | File sharing |
|---|---|---|---|
| Jobs | Different machines | In parallel (by default) | Through artifacts only |
| Steps | The same machine | Sequentially | Yes (same filesystem) |
Practical consequences:
- a file created in step 1 is available in step 2 of the same job;
- a file created in job A does not exist in job B (different machines);
- for a job to wait for another, declare it with
needs:; - to share files between jobs, you need artifacts.
Why this isolation?
Isolation between jobs is a security and reliability feature:
- parallelism: jobs can run on different machines, simultaneously;
- reproducibility: every job starts from a clean environment;
- failure isolation: a crashed job does not corrupt the others.
The classic beginner mistake
If you miss that distinction, you will make this mistake:
# ❌ WRONG: the files built in "build" are NOT available in "deploy"jobs: build: runs-on: ubuntu-24.04 steps: - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 - run: npm run build # Creates a dist/ folder
deploy: needs: build # Waits for build to finish... runs-on: ubuntu-24.04 # ...but this is ANOTHER MACHINE! steps: - run: ./deploy.sh dist/ # ❌ Error: dist/ not foundWhy does it not work? The deploy job runs on a brand new, empty machine.
The dist/ folder created by build stayed on the old machine, which has been
destroyed.
The fix: use artifacts to carry files from one job to the next.
Triggering events (on:)
The on: section defines when your workflow starts. It is the trigger:
the GitHub event that causes the file to run.
This chapter covers the essentials. For advanced filters (branches, tags,
paths), cron scheduling, workflow_run and the traps of
pull_request_target, the guide
Triggers and events
goes further.
The most common triggers
Five triggers cover the vast majority of needs. Remember the distinction between
push and pull_request: the first reacts to code already integrated into a
branch, the second to code proposed in a PR, before it is merged. That
difference structures the security of your pipelines, since a PR coming from a
fork must never run with your secrets.
| Trigger | When it fires | Typical use case |
|---|---|---|
push | Code pushed to a branch | Tests, build |
pull_request | A PR opened or updated | Validation before merge |
workflow_dispatch | A manual button | On-demand deployment |
schedule | A scheduled time (cron) | Nightly security scans |
release | A release published | Publishing packages |
Push and pull requests (the most common)
The example below shows the two most useful filters. On push, the branches:
and paths: patterns avoid running the workflow for nothing, and the ! prefix
excludes a pattern, here the Markdown files. On pull_request, the types:
list controls when it fires; synchronize covers a new commit pushed to an
already open PR, and it is the one people forget most often, which makes it look
as if the PR is no longer being validated.
on: # When someone pushes code push: branches: - main # An exact branch - 'release/*' # A wildcard: release/v1, release/v2... paths: - 'src/**' # Only when files under src/ change - '!**/*.md' # But not the Markdown files
# When a PR is opened or updated pull_request: branches: [main] types: [opened, synchronize, reopened]Manual triggering (very handy for deployments)
on: workflow_dispatch: inputs: environment: description: 'Where to deploy?' required: true type: choice options: - staging - productionYou will then see a "Run workflow" button in the GitHub interface, with a dropdown to pick the environment.
Scheduling (for nightly tasks)
on: schedule: # Every day at 03:00 (UTC) - cron: '0 3 * * *'Jobs: your units of work
A job is a set of steps running on the same virtual machine. It is the base unit of your workflow.
What you need to know about jobs
- every job starts on a fresh machine;
- jobs run in parallel by default;
- a job can depend on other jobs (with
needs:); - a job can hold its own permissions, tighter than the workflow's;
- a job can target a specific environment (staging, production).
The structure of a job
The skeleton below gathers the keys of a job in the order you read them. Only
two are required, runs-on and steps; everything else tunes the behaviour.
Spot timeout-minutes:: without it, a job inherits a default limit of six
hours, more than enough to burn your GitHub minutes on a stuck test. Setting
it low, at fifteen or thirty minutes, is the reflex that costs least and returns
most.
jobs: my-job: # A unique identifier (letters, digits, dashes, _) name: "My great job" # The name shown in the UI (optional, recommended) runs-on: ubuntu-24.04 # The execution machine (required)
# Behaviour options timeout-minutes: 30 # A time limit (default: 6 hours!) continue-on-error: false # Stop the workflow if this job fails (default)
# An execution condition if: github.event_name == 'push'
# Environment variables for this job only env: DEBUG: true
# Specific permissions (tighter than the workflow's) permissions: contents: read
# The target environment (optional, for deployments) environment: staging
# The steps to run steps: - run: echo "I am a step"The key properties of a job
This table lists the same keys, this time with whether they are required. The
two rows marked Yes are the bare minimum; the others are justified case by
case. Two defaults are worth memorising because they surprise people:
timeout-minutes is 360 minutes until you set it, and without a
permissions: block the job's GITHUB_TOKEN inherits the rights defined above,
potentially with write access.
| Property | Required | Description |
|---|---|---|
runs-on | Yes | The virtual machine (ubuntu-24.04, windows-2025, macos-15) |
steps | Yes | The list of steps to run |
name | No | The name shown in the interface (recommended) |
needs | No | Jobs to wait for before starting |
if | No | An execution condition |
timeout-minutes | No | Maximum duration before failure (default: 360 min!) |
permissions | No | GITHUB_TOKEN permissions for this job |
environment | No | The target environment (staging, production) |
env | No | Environment variables |
Dependencies between jobs
By default, every job starts at the same time. To create a sequence, use
needs::
jobs: test: runs-on: ubuntu-24.04 steps: - run: npm test
build: needs: test # Waits for "test" to finish and pass runs-on: ubuntu-24.04 steps: - run: npm run build
deploy: needs: [test, build] # Waits for SEVERAL jobs runs-on: ubuntu-24.04 steps: - run: ./deploy.shA visualisation of the run:
test ──────────┐ ├──→ deploybuild ─────────┘ ↑ │ (build waits for test)Steps: your individual actions
Steps are the concrete actions you want to run. Each step does one thing: clone the code, install dependencies, run tests, deploy.
Two ways to write a step
There are two kinds of steps, and you will use both constantly:
| Kind | Syntax | Usage | Example |
|---|---|---|---|
| Action | uses: | A reusable component | Clone the repo, set up Node |
| Command | run: | A shell script | npm install, pytest, make |
steps: # ══════════════════════════════════════════════════════════════════════════════ # KIND 1: an action (uses), a reusable component # ══════════════════════════════════════════════════════════════════════════════ - name: Check out the code uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: # The action parameters fetch-depth: 0
- name: Set up Node.js uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 with: node-version: '20' cache: 'npm' # Enables dependency caching
# ══════════════════════════════════════════════════════════════════════════════ # KIND 2: a shell command (run), a script to execute # ══════════════════════════════════════════════════════════════════════════════ - name: Install the dependencies run: npm ci
# Several commands with the pipe | - name: Build and test run: | echo "Building..." npm run build echo "Testing..." npm testUseful step properties
Every step can carry properties that control its behaviour:
| Property | Description | Example |
|---|---|---|
name | The name shown in the logs | "Run unit tests" |
if | An execution condition | if: success(), if: failure() |
continue-on-error | Do not fail the job if this step fails | true |
timeout-minutes | Maximum duration of the step | 15 |
working-directory | The execution folder | ./frontend |
env | Environment variables for this step | DEBUG: true |
steps: # Runs only if the earlier steps succeeded (the default) - name: Deploy if: success() run: ./deploy.sh
# ALWAYS runs (even when a step failed) - name: Cleanup if: always() run: rm -rf temp/
# Runs only on failure - name: Notify the team if: failure() run: ./notify-slack.sh "The build failed!"
# Carry on even if this step fails - name: Optional analysis continue-on-error: true run: npm run analyze
# A custom timeout (useful for slow tests) - name: Integration tests timeout-minutes: 15 run: npm run test:integration
# Run inside a specific folder - name: Frontend tests working-directory: ./frontend run: npm testContext variables
GitHub exposes context variables holding information about the current run.
You read them with the ${{ context.variable }} syntax.
The most useful contexts
Six contexts cover most workflows. The row to handle carefully is secrets: a
secret value interpolated directly into a run: can leak into the logs, so it
must travel through an env: block. Note as well that needs is only populated
for jobs declared as dependencies: needs.build.outputs.version only exists when
the current job carries needs: build.
| Context | What it holds | Examples |
|---|---|---|
github | Information about the event | github.ref_name, github.actor, github.sha |
secrets | Your secrets | secrets.DOCKER_TOKEN |
vars | Your variables | vars.NODE_VERSION |
env | Environment variables | env.MY_VAR |
matrix | The current matrix value | matrix.os, matrix.node |
needs | Outputs of earlier jobs | needs.build.outputs.version |
Concrete examples
# Print some context information- name: Debug env: BRANCH: ${{ github.ref_name }} ACTOR: ${{ github.actor }} SHA: ${{ github.sha }} run: | echo "Branch: $BRANCH" echo "Author: $ACTOR" echo "SHA: $SHA"
# Using a secret: pass it through env:, never interpolated into run:- name: Docker login env: DOCKER_TOKEN: ${{ secrets.DOCKER_TOKEN }} run: echo "$DOCKER_TOKEN" | docker login -u user --password-stdin
# A condition based on the context- name: Deploy (main only) if: github.ref_name == 'main' run: ./deploy.shThe four golden rules of workflows
These rules will spare you 90% of the problems.
1. One workflow, one responsibility
Avoid giant workflows that do everything. Prefer several specialised files:
.github/workflows/├── ci.yml # Tests and build├── security.yml # Vulnerability scans├── deploy.yml # Deployment└── release.yml # Version publishingWhy? A failing workflow tells you immediately where the problem is. "The deployment failed" is more useful than "the CI workflow failed".
2. Always start with actions/checkout
This is beginner mistake number one:
# ❌ The code is NOT there by magic!steps: - run: npm test # Error: package.json not found
# ✅ Always check out firststeps: - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 - run: npm test3. Minimal permissions
Always declare permissions explicitly, at the minimum needed:
permissions: contents: read # Read-only by default
jobs: publish: permissions: contents: read packages: write # Write access only for the job that publishes steps: [...]See Securing GitHub Actions for the details.
4. Name every step
Logs are your debugging tool. Explicit names save you time:
# ❌ Hard to read in the logs (and not pinned!)steps: - uses: actions/checkout@v4 - run: npm ci - run: npm test
# ✅ Clear, understandable and secure logssteps: - name: Checkout repository uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 - name: Install dependencies run: npm ci - name: Run unit tests run: npm testYour first complete workflow
To sum up, here is a complete and secure workflow for a Node.js application:
name: CI
on: push: branches: [main, develop] pull_request: branches: [main]
permissions: contents: read
jobs: test: runs-on: ubuntu-24.04 steps: - name: Checkout uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Setup Node.js uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 with: node-version: '20' cache: 'npm'
- name: Install dependencies run: npm ci
- name: Run linter run: npm run lint
- name: Run tests run: npm testThe classic mistakes (and how to avoid them)
Everybody makes these mistakes at the start. Understanding why they happen helps you avoid them, and fix them quickly when they do.
Mistake 1: forgetting the checkout
The symptom: your workflow fails immediately with "file not found", "package.json not found" or "command not found".
Why it happens: a GitHub Actions runner starts from an empty machine.
Your code is not there by magic, you have to download it from the repository
explicitly with actions/checkout.
# ❌ The runner is empty, the code does not exist!steps: - run: npm test # Error: package.json not found
# ✅ Fetch the code first, then worksteps: - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 - run: npm test # Now package.json existsHow to remember it: think of a courier who must pick up a parcel before delivering it. The checkout is picking up the parcel, your code.
Mistake 2: exposing a secret in the logs
The symptom: your API key, token or password appears in clear text in the workflow logs, visible to everyone with access to the repository.
Why it happens: GitHub masks secrets (***) only when they come through
the secrets context. If you copy them into a variable or print them with
echo, the masking stops working.
# ❌ DANGER! The secret appears in clear text in the logs- run: echo "Token: ${{ secrets.API_KEY }}"
# ❌ DANGER! Copying into a variable then echoing means no masking- run: | TOKEN=${{ secrets.API_KEY }} echo "Using $TOKEN" # The token is visible!
# ✅ Pass it through env: the masking works- name: Deploy env: API_KEY: ${{ secrets.API_KEY }} run: ./deploy.sh # The script reads $API_KEY, never printedThe golden rule: never echo a secret, not even to debug. If you need
to check that a secret exists, test its length:
- run: | if [ -n "$API_KEY" ]; then echo "Secret present (${#API_KEY} characters)" fi env: API_KEY: ${{ secrets.API_KEY }}Mistake 3: unintended parallel jobs
The symptom: your deployment job starts before the build has finished, or fails because the files produced by the earlier job do not exist.
Why it happens: by default, every job of a workflow starts at the same
time. That is deliberate, for speed, but it means dependencies must be
declared explicitly with needs:.
# ❌ build and deploy start AT THE SAME TIME!jobs: build: runs-on: ubuntu-24.04 steps: - run: npm run build # Creates dist/
deploy: runs-on: ubuntu-24.04 steps: - run: ./deploy.sh dist/ # dist/ does not exist yet!
# ✅ deploy waits for build to finishjobs: build: runs-on: ubuntu-24.04 steps: - run: npm run build
deploy: needs: build # ← Waits for build to end runs-on: ubuntu-24.04 steps: - run: ./deploy.sh dist/Careful: even with needs:, the files created in build are not
available in deploy (different machines). Use artifacts to share files between
jobs.
Mistake 4: the default timeout is too long
The symptom: a buggy job (an infinite loop, a wait on a resource that never answers) runs for hours, burning your GitHub Actions minutes.
Why it happens: the default timeout is six hours (360 minutes). A single bug can cost a lot of time and money.
# ❌ If npm test loops, the job runs for six hours!jobs: test: runs-on: ubuntu-24.04 steps: - run: npm test
# ✅ An explicit timeout: fail fast when something goes wrongjobs: test: runs-on: ubuntu-24.04 timeout-minutes: 15 # Fails after 15 minutes at most steps: - run: npm testSuggested timeouts:
| Kind of job | Suggested timeout |
|---|---|
| Unit tests | 10-15 min |
| Application build | 15-20 min |
| Integration tests | 20-30 min |
| Deployment | 10-15 min |
Recap
This table condenses the four traps that catch beginners most often. Read it by the Symptom column: that is what you will see in the logs, and it is your entry point for finding the cause. The first two hit everybody; the last two appear as soon as a pipeline chains several jobs.
| Mistake | Symptom | Fix |
|---|---|---|
| No checkout | "file not found" | Add actions/checkout first |
| Exposed secret | A token visible in the logs | Use env: instead of echo |
| Parallel jobs | Deployment before the build ends | Add needs: previous_job |
| No timeout | A job running for hours | Add timeout-minutes: |
Validating your workflows before pushing
You write a workflow, you push it to GitHub, and then a syntax error. You fix it, push again, another error. That frustrating cycle is avoidable.
Two complementary tools validate your workflows locally:
| Tool | What it does | When to use it |
|---|---|---|
| actionlint | Static analysis (syntax, types, references) | Before every commit |
| Scorecard | Security audit (permissions, pinning, dangerous patterns) | Before going to production |
actionlint: the indispensable linter
actionlint finds errors without running the workflow: YAML syntax, misspelled properties, non-existent actions, invalid expressions, security problems.
# Installationbrew install actionlint # macOS# orgo install github.com/rhysd/actionlint/cmd/actionlint@latest
# Validationactionlint # Every workflowactionlint .github/workflows/ci.yml # One specific fileExample output:
.github/workflows/ci.yml:11:11: input "node-verion" is not defined in action "actions/setup-node@v4" [action]The error is found in milliseconds, not after a push and a wait of several minutes. Mind the division of labour: actionlint does not report actions that are unpinned by SHA, that is Scorecard's job, covered next.
Scorecard: the security audit
OpenSSF Scorecard audits the security posture of your workflows. Three checks are particularly useful:
| Check | What it verifies |
|---|---|
| Token-Permissions | write permissions at job level (not workflow level) |
| Pinned-Dependencies | Actions pinned by SHA |
| Dangerous-Workflow | Dangerous patterns (pull_request_target plus a fork checkout) |
# Installationbrew install scorecard # macOS# orgo install github.com/ossf/scorecard/v5/cmd/scorecard@latest
# Auditing the workflowsscorecard --local . --checks Token-Permissions,Pinned-Dependencies,Dangerous-Workflow
# A full audit with detailsscorecard --local . --show-detailsExample output:
Token-Permissions: 10/10 permissions are declared at job level
Pinned-Dependencies: 6/10 actions/checkout@v4 is not pinned by hash actions/setup-node@39370e... is pinned
Dangerous-Workflow: 10/10 no dangerous patterns detectedThe target: aim for 8 or more on each check. Scoring 10/10 on Token-Permissions and Dangerous-Workflow is easily reachable.
A recommended validation workflow
Before every push, run this script:
#!/usr/bin/env bashset -uo pipefail
echo "Static validation with actionlint..."if ! actionlint; then echo "Syntax errors detected" >&2 exit 1fi
echo "Security audit with Scorecard..."scorecard --local . --checks Token-Permissions,Pinned-Dependencies,Dangerous-Workflow \ || echo "Security problems detected (see above)" >&2
echo "Dry run with act..."if ! act -n; then echo "Execution errors detected" >&2 exit 1fi
echo "Workflows validated."The Scorecard audit does not stop the script: an imperfect score is a signal to examine, not necessarily a blocker. The other two steps do stop everything on failure, because a syntax or execution error makes the workflow unusable.
Put it into practice
You now have everything you need to build your own workflows. Theory is good, but nothing beats practice to anchor the concepts.
Lab 01: your first workflow
Automate the test run of an application. You will apply everything you have just learned: workflow structure, checkout, permissions, good practices.
Next steps
- Contexts and expressions: the language of workflows, and how data travels between steps and jobs.
- Triggers and events: every event that can start a workflow, and the filters that keep runs useful.
- Securing GitHub Actions: the security module, once the syntax holds no more surprises.