Skip to content
Français
CI/CD & Automatisation medium

GitHub Actions workflows: the complete guide

70 min de lecture

Read this page in French

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:

QuestionYAML sectionExample
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 interface
run-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 build

The 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

ConceptEnvironmentExecutionFile sharing
JobsDifferent machinesIn parallel (by default)Through artifacts only
StepsThe same machineSequentiallyYes (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 found

Why 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.

TriggerWhen it firesTypical use case
pushCode pushed to a branchTests, build
pull_requestA PR opened or updatedValidation before merge
workflow_dispatchA manual buttonOn-demand deployment
scheduleA scheduled time (cron)Nightly security scans
releaseA release publishedPublishing 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
- production

You 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.

PropertyRequiredDescription
runs-onYesThe virtual machine (ubuntu-24.04, windows-2025, macos-15)
stepsYesThe list of steps to run
nameNoThe name shown in the interface (recommended)
needsNoJobs to wait for before starting
ifNoAn execution condition
timeout-minutesNoMaximum duration before failure (default: 360 min!)
permissionsNoGITHUB_TOKEN permissions for this job
environmentNoThe target environment (staging, production)
envNoEnvironment 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.sh

A visualisation of the run:

test ──────────┐
├──→ deploy
build ─────────┘
│ (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:

KindSyntaxUsageExample
Actionuses:A reusable componentClone the repo, set up Node
Commandrun:A shell scriptnpm 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 test

Useful step properties

Every step can carry properties that control its behaviour:

PropertyDescriptionExample
nameThe name shown in the logs"Run unit tests"
ifAn execution conditionif: success(), if: failure()
continue-on-errorDo not fail the job if this step failstrue
timeout-minutesMaximum duration of the step15
working-directoryThe execution folder./frontend
envEnvironment variables for this stepDEBUG: 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 test

Context 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.

ContextWhat it holdsExamples
githubInformation about the eventgithub.ref_name, github.actor, github.sha
secretsYour secretssecrets.DOCKER_TOKEN
varsYour variablesvars.NODE_VERSION
envEnvironment variablesenv.MY_VAR
matrixThe current matrix valuematrix.os, matrix.node
needsOutputs of earlier jobsneeds.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.sh

The 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 publishing

Why? 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 first
steps:
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
- run: npm test

3. 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 logs
steps:
- name: Checkout repository
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
- name: Install dependencies
run: npm ci
- name: Run unit tests
run: npm test

Your 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 test

The 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 work
steps:
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
- run: npm test # Now package.json exists

How 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 printed

The 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 finish
jobs:
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 wrong
jobs:
test:
runs-on: ubuntu-24.04
timeout-minutes: 15 # Fails after 15 minutes at most
steps:
- run: npm test

Suggested timeouts:

Kind of jobSuggested timeout
Unit tests10-15 min
Application build15-20 min
Integration tests20-30 min
Deployment10-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.

MistakeSymptomFix
No checkout"file not found"Add actions/checkout first
Exposed secretA token visible in the logsUse env: instead of echo
Parallel jobsDeployment before the build endsAdd needs: previous_job
No timeoutA job running for hoursAdd 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:

ToolWhat it doesWhen to use it
actionlintStatic analysis (syntax, types, references)Before every commit
ScorecardSecurity 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.

Fenêtre de terminal
# Installation
brew install actionlint # macOS
# or
go install github.com/rhysd/actionlint/cmd/actionlint@latest
# Validation
actionlint # Every workflow
actionlint .github/workflows/ci.yml # One specific file

Example 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:

CheckWhat it verifies
Token-Permissionswrite permissions at job level (not workflow level)
Pinned-DependenciesActions pinned by SHA
Dangerous-WorkflowDangerous patterns (pull_request_target plus a fork checkout)
Fenêtre de terminal
# Installation
brew install scorecard # macOS
# or
go install github.com/ossf/scorecard/v5/cmd/scorecard@latest
# Auditing the workflows
scorecard --local . --checks Token-Permissions,Pinned-Dependencies,Dangerous-Workflow
# A full audit with details
scorecard --local . --show-details

Example 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 detected

The target: aim for 8 or more on each check. Scoring 10/10 on Token-Permissions and Dangerous-Workflow is easily reachable.

Before every push, run this script:

validate-workflows.sh
#!/usr/bin/env bash
set -uo pipefail
echo "Static validation with actionlint..."
if ! actionlint; then
echo "Syntax errors detected" >&2
exit 1
fi
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 1
fi
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.

Open Lab 01

Next steps

Is this site useful to you?

Fewer than 1% of readers support this site.

I maintain more than 700 free guides, with no ads and no tracking. Any support, even a symbolic one, helps cover hosting and keeps these resources free. Thank you for the help.

The form does not show? Open Ko-fi in a new tab.

Subscribe and follow my DevSecOps work on LinkedIn