Your First GitHub Actions Workflow

GitHub Actions is GitHub’s built-in automation platform: you describe a set of steps in a YAML file, commit that file to your repository, and GitHub runs those steps automatically whenever something happens — a push, a pull request, a schedule, or a manual click. The most common first use is continuous integration (CI): automatically running your test suite every time someone pushes code, so broken code is caught before it’s merged. This lesson walks you through the anatomy of a workflow file and has you write, commit, and watch your first one run.

Overview / How it works

A GitHub Actions workflow is a YAML file that lives in a specific folder in your repository: .github/workflows/. GitHub watches that folder. Any .yml file there that is validly formatted becomes an active workflow the moment it’s pushed to GitHub — there’s no separate registration step, no dashboard toggle to flip. The file itself is the configuration.

A workflow is triggered by events (defined under the on: key) — most commonly a push or a pull_request, but also things like a schedule (cron-based, runs even with no code changes) or workflow_dispatch (a manual “Run workflow” button in the GitHub UI). When a matching event occurs, GitHub spins up a fresh, temporary virtual machine called a runner (Ubuntu, Windows, or macOS — ubuntu-latest is the common default), checks out your repository’s code onto it, and executes the jobs you defined.

Each job is a sequence of steps. A step either runs a shell command (run:) or invokes a reusable, pre-built action (uses:) — a packaged chunk of automation someone else wrote, referenced like actions/checkout@v4. That particular action is nearly universal: by default the runner is a blank machine with no copy of your repository at all, so almost every workflow’s first step is checking your code out onto it. Multiple jobs in the same workflow run in parallel on separate, independent runners by default (unless you declare a dependency with needs:), each starting from a clean slate — nothing persists between jobs unless you explicitly upload/download it as an artifact.

It’s worth being precise about what Actions is not: it is not a Git hook. A Git hook (like pre-commit) runs locally, on your own machine, before Git completes an operation. A GitHub Actions workflow runs remotely, on GitHub’s infrastructure, triggered by something happening to the repository on GitHub’s servers — it has no way to stop a git commit or git push from succeeding locally. Instead, its job is to react after the push, verify the result (run tests, lint, build), and report pass/fail back onto the commit or pull request as a status check.

Syntax

Every workflow file follows the same broad shape:

name: <workflow name shown in the Actions tab>

on:
  <event>:
    <event options>

jobs:
  <job-id>:
    runs-on: <runner image>
    steps:
      - name: <step description>
        uses: <action-name>@<version>
      - name: <step description>
        run: <shell command>
Key Meaning
name Human-readable label for the whole workflow; shown in the Actions tab. Optional but recommended.
on The event(s) that trigger the workflow. Can be a single event, a list, or an object with per-event filters like branches.
jobs A map of one or more jobs. Each key (e.g. build, test) is an arbitrary job id you choose.
runs-on The virtual machine image the job executes on, e.g. ubuntu-latest, windows-latest, macos-latest.
steps An ordered list of commands/actions run sequentially within the job; if one fails, later steps in that job are skipped by default.
uses Runs a reusable action from a repository, e.g. actions/checkout@v4. Always pin a version.
run Runs a raw shell command (or several, one per line) directly on the runner.
with Input parameters passed to an action used via uses.
env Environment variables, settable at the workflow, job, or step level.

Examples

Example 1: A minimal CI workflow for a Node.js project

Create the folder and file, then paste in a workflow that installs dependencies and runs tests on every push and pull request targeting main:

mkdir -p .github/workflows
touch .github/workflows/ci.yml

Now put this inside .github/workflows/ci.yml:

name: CI

on:
  push:
    branches: [ "main" ]
  pull_request:
    branches: [ "main" ]

jobs:
  build:
    runs-on: ubuntu-latest
    steps:
      - name: Check out repository code
        uses: actions/checkout@v4

      - name: Set up Node.js
        uses: actions/setup-node@v4
        with:
          node-version: "20"

      - name: Install dependencies
        run: npm ci

      - name: Run tests
        run: npm test

Commit and push it like any other file:

git add .github/workflows/ci.yml
git commit -m "ci: add GitHub Actions workflow to run tests"
git push origin main

Output:

[main a1b2c3d] ci: add GitHub Actions workflow to run tests
 1 file changed, 22 insertions(+)
 create mode 100644 .github/workflows/ci.yml
Enumerating objects: 4, done.
Writing objects: 100% (3/3), 621 bytes | 621.00 KiB/s, done.
To github.com:yourname/your-repo.git
   9f8e7d6..a1b2c3d  main -> main

The push itself triggers the workflow — open the Actions tab on GitHub and you’ll see a run named “CI” appear within seconds, progress through “Set up Node.js”, “Install dependencies”, “Run tests”, and finish with a green checkmark (or a red X if a test failed).

Example 2: Testing against multiple Node.js versions with a matrix

Real projects often need to verify they work across several runtime versions. A strategy.matrix block runs the same job multiple times in parallel, once per combination:

name: CI

on:
  push:
    branches: [ "main" ]
  pull_request:
    branches: [ "main" ]

jobs:
  build:
    runs-on: ubuntu-latest
    strategy:
      matrix:
        node-version: [ "18", "20", "22" ]
    steps:
      - uses: actions/checkout@v4
      - name: Set up Node.js ${{ matrix.node-version }}
        uses: actions/setup-node@v4
        with:
          node-version: ${{ matrix.node-version }}
      - run: npm ci
      - run: npm test

This produces three independent runner instances — one per Node.js version — all running concurrently, each reported separately in the Actions tab (e.g. “build (18)”, “build (20)”, “build (22)”). If version 18 fails but 20 and 22 pass, you immediately know it’s version-specific.

Example 3: Checking on a run and re-running a failure with the GitHub CLI

You don’t have to leave the terminal to check Actions results if you have the gh CLI installed and authenticated:

gh run list --branch main --limit 5
gh run view --log-failed
gh run rerun --failed

Output:

STATUS  TITLE                                    WORKFLOW  BRANCH  EVENT  ID
X       ci: add GitHub Actions workflow...       CI        main    push   1234567890
✓       fix: correct off-by-one in parser        CI        main    push   1234566789

gh run list shows recent workflow runs, gh run view --log-failed prints only the log output of failed steps (much faster than scrolling the web UI), and gh run rerun --failed re-runs only the jobs that failed rather than the whole workflow.

How it works step by step

When you push a commit that matches a workflow’s on: trigger, here is what actually happens on GitHub’s side:

  • GitHub reads every .yml/.yaml file in .github/workflows/ at the tip of the pushed branch and checks each one’s on: conditions against the event.
  • For each matching workflow, GitHub creates a workflow run and, for every job inside it, provisions a brand-new, ephemeral virtual machine matching runs-on.
  • The runner has nothing on it — no clone of your repo, no dependencies. That’s why the first step is almost always actions/checkout@v4, which performs a shallow git clone of your repository at the triggering commit onto the runner’s filesystem.
  • Each subsequent step executes in order, in the same working directory, sharing filesystem state with earlier steps in the same job (but not with other jobs, unless you explicitly pass data via artifacts or outputs).
  • The job’s overall status (success/failure) is reported back to GitHub and attached to the commit and any associated pull request as a status check — this is what produces the green check or red X you see next to a commit or PR.
  • Once the job finishes, the virtual machine is destroyed. Nothing persists to the next run automatically.

Common Mistakes

Mistake 1 — putting the workflow file in the wrong path. GitHub only looks in .github/workflows/, not .github/workflow/ (singular) or a top-level workflows/ folder.

# Wrong: GitHub will silently ignore this file
mkdir -p .github/workflow
touch .github/workflow/ci.yml

Fix: the folder name must be exactly .github/workflows, plural, at the repository root.

Mistake 2 — forgetting actions/checkout. Without it, npm ci or any command that expects your project files will fail because the runner’s working directory is empty:

# Wrong: no checkout step
jobs:
  build:
    runs-on: ubuntu-latest
    steps:
      - run: npm ci   # fails: no package.json on this fresh runner

Fix: always make actions/checkout@v4 the first step of any job that touches repository files.

Mistake 3 — broken YAML indentation. YAML is whitespace-sensitive; a step accidentally indented at the wrong level silently attaches to the wrong parent or breaks parsing entirely:

jobs:
  build:
    runs-on: ubuntu-latest
     steps:              # wrong: extra space breaks the mapping
      - run: npm test

Fix: use a consistent 2-space indent throughout, and let your editor’s YAML linter flag misalignment before you commit.

Mistake 4 — committing a real secret instead of using GitHub Secrets. Never hardcode API keys or tokens directly in a workflow file, since it’s plain text in your Git history forever. Store them under Settings → Secrets and variables → Actions and reference them as ${{ secrets.MY_TOKEN }} instead.

Best Practices

  • Pin actions to a specific major version (actions/checkout@v4), not @main or @latest, so an upstream change doesn’t silently break your pipeline.
  • Keep workflow names and job/step names descriptive — they’re what you see at a glance in the Actions tab when a run fails.
  • Scope triggers narrowly (branches: [ "main" ]) to avoid burning CI minutes on branches or forks you don’t need to test yet.
  • Use secrets for anything sensitive — never environment variables baked directly into the YAML.
  • Cache dependencies (e.g. with actions/setup-node‘s built-in cache: "npm" option) once your workflow is working, to speed up repeated runs.
  • Treat a required status check as a gate: enable branch protection on main so pull requests can’t merge until CI passes.
  • Start with the smallest workflow that proves the concept, then add matrix testing, linting, and deployment jobs incrementally.

Practice Exercises

  • Exercise 1: In a repository of your own (any language), add .github/workflows/ci.yml that checks out the code and runs a trivial command like echo "Hello from Actions". Push it and confirm a green run appears in the Actions tab.
  • Exercise 2: Modify that workflow so it also triggers on pull_request. Create a branch, make a small change, open a pull request against main, and confirm the status check appears directly on the PR page.
  • Exercise 3: Intentionally break something (e.g. add a step that runs exit 1) and push it. Use gh run view --log-failed (or the Actions tab) to find the failure, then fix it and push again to confirm the run turns green.

Summary

  • GitHub Actions workflows are YAML files stored in .github/workflows/; GitHub picks them up automatically on push, no separate registration needed.
  • Workflows are triggered by events (on:), run as one or more jobs, each made of ordered steps on a fresh, disposable virtual machine.
  • Steps either run shell commands (run:) or call reusable actions (uses:); actions/checkout@v4 is almost always the first step so your code exists on the runner at all.
  • Job results post back to GitHub as status checks visible on commits and pull requests — this is how CI gates merges.
  • Pin action versions, scope triggers, use GitHub Secrets for anything sensitive, and never store real secrets in the workflow file itself.