Skip to main content
CI/CD·8 min read

GitHub Actions Workflow Fundamentals — Events, Jobs, Steps, and a Real Pipeline

A practical guide to GitHub Actions: how workflow YAML, events, jobs, steps, runners, and actions fit together, plus a realistic build-test-deploy pipeline with matrix builds, secrets, and job dependencies.

DT

DevOps Engineer & Technical Writer

The Problem

Most teams adopt GitHub Actions by copying a .yml file from a tutorial, tweaking it until the green checkmark appears, and never touching it again. Then a deploy fires on the wrong branch, a secret leaks into a log, or the test job runs before the build finishes. GitHub Actions is not complicated — but the pieces (events, jobs, steps, runners, actions) only make sense once you see how they connect.

GITHUB ACTIONS — EVENT TRIGGERS WORKFLOW, WORKFLOW RUNS JOBS, JOBS RUN STEPS EVENTS push pull_request schedule workflow_dispatch WORKFLOW (.github/workflows/ci.yml) job: build step: checkout step: setup-node step: npm ci step: npm run build job: test needs: build step: npm test matrix: node 18/20 job: deploy needs: test if: main only uses: secrets All jobs run on runners (ubuntu-latest). needs: creates ordered dependencies between jobs.

The mental model is a chain: an event triggers a workflow, the workflow runs one or more jobs, and each job runs an ordered list of steps on a runner. Everything else — matrix builds, secrets, dependencies — is a refinement of that chain.

Where Workflows Live

A workflow is a single YAML file in .github/workflows/. You can have many of them, and GitHub evaluates each one independently against incoming events.

.github/

└── workflows/

├── ci.yml # build + test on every push and PR

├── deploy.yml # deploy on push to main

└── nightly.yml # scheduled jobs

The file name is cosmetic — the name: field inside controls what shows up in the Actions tab. GitHub picks up any .yml or .yaml file in that folder automatically.

Events: What Triggers a Workflow

The on: key declares which events start a run. The most common ones:

EventFires when
<code class="inline-code">push</code>Commits are pushed to a branch or tag
<code class="inline-code">pull_request</code>A PR is opened, updated, or reopened
<code class="inline-code">workflow_dispatch</code>Someone clicks "Run workflow" manually
<code class="inline-code">schedule</code>A cron expression matches (UTC)
on:

push:

branches: [main]

pull_request:

branches: [main]

workflow_dispatch: # manual button in the UI

schedule:

- cron: "0 2 *" # every day at 02:00 UTC

Scope your triggers tightly. A workflow that runs on push to every branch burns minutes and clutters the Actions tab. Most CI should run on PRs and on main.

Jobs, Steps, and Runners

A job is a unit of work that runs on a fresh runner — a VM GitHub spins up for you. Within a job, steps run sequentially and share the same filesystem. Across jobs, there is no shared disk unless you explicitly pass artifacts.

jobs:

build:

runs-on: ubuntu-latest # the runner

steps:

- uses: actions/checkout@v4 # an action (reusable step)

- uses: actions/setup-node@v4

with:

node-version: 20

- run: npm ci # a shell command

- run: npm run build

Two kinds of steps exist:

  • uses: runs a published action — a reusable, versioned package (always pin to a tag like @v4).
  • run: executes a shell command directly on the runner.

Matrix Builds

A matrix runs the same job across multiple variable combinations in parallel — ideal for testing across language versions or operating systems.

jobs:

test:

runs-on: ${{ matrix.os }}

strategy:

fail-fast: false

matrix:

os: [ubuntu-latest, windows-latest]

node: [18, 20]

steps:

- uses: actions/checkout@v4

- uses: actions/setup-node@v4

with:

node-version: ${{ matrix.node }}

- run: npm ci

- run: npm test

This single block produces four parallel runs (2 OSes × 2 Node versions). fail-fast: false lets every combination finish even if one fails, so you see the full picture.

Secrets and Environment Variables

Never hard-code tokens. Store them in repository or environment secrets and reference them with the secrets context. Set non-sensitive config with env.

jobs:

deploy:

runs-on: ubuntu-latest

env:

NODE_ENV: production # non-sensitive config

steps:

- uses: actions/checkout@v4

- run: ./deploy.sh

env:

AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }}

AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }}

Secrets are masked in logs automatically, but only if you reference them through the secrets context — a token echoed from your own script can still leak.

Job Dependencies with <code class="inline-code">needs</code>

By default, jobs run in parallel. Use needs: to force an order so deploy only happens after tests pass.

jobs:

build:

runs-on: ubuntu-latest

steps:

- uses: actions/checkout@v4

- run: npm ci && npm run build

test:

needs: build # waits for build to succeed

runs-on: ubuntu-latest

steps:

- uses: actions/checkout@v4

- run: npm ci && npm test

deploy:

needs: test # waits for test to succeed

if: github.ref == 'refs/heads/main' # only on main

runs-on: ubuntu-latest

steps:

- uses: actions/checkout@v4

- run: ./deploy.sh

The if: condition on deploy is the guardrail that stops a feature branch from shipping to production. needs: plus if: together give you a safe, ordered build → test → deploy pipeline.

Common Mistakes to Avoid

  • Triggering on every branch. Scope push to main and let pull_request cover the rest, or you will waste minutes and money.
  • Using unpinned actions. actions/checkout@main can change under you. Pin to a tag (@v4) or a commit SHA for supply-chain safety.
  • Expecting state to persist across jobs. Each job gets a clean runner. Pass data with actions/upload-artifact / download-artifact, not by assuming shared disk.
  • Putting deploy and build in one job. Split them so you can require tests to pass and gate deploy behind a branch condition.
  • Echoing secrets in scripts. Only the secrets context is auto-masked. Printing a token yourself defeats that.

FAQ

What is the difference between a job and a step?

A job is a group of steps that runs on its own fresh runner; steps are the individual commands or actions inside a job. Steps in the same job share a filesystem and run in order, while jobs run in parallel unless linked with needs:.

When should I use <code class="inline-code">uses:</code> versus <code class="inline-code">run:</code>?

Use uses: to pull in a published, reusable action like actions/checkout or actions/setup-node, and use run: for your own shell commands. Actions save you from reinventing common setup logic and are versioned, so prefer them for standard tasks.

How do I make one job wait for another?

Add needs: <job-id> to the dependent job. It will only start after the named job (or jobs) completes successfully, which is how you build an ordered build → test → deploy chain.

Are secrets safe to use in workflows?

Yes, when referenced through the secrets context — GitHub masks those values in logs. Risk comes from printing them yourself or passing them to untrusted code, especially in workflows triggered by pull requests from forks.