Guides/GitHub ActionsGitHub Actions/GitHub Actions Fundamentals

GitHub Actions Fundamentals

The GitHub Actions mental model that makes everything click: an event triggers a workflow, which runs jobs on runners, each job a series of steps. Plus your first working CI pipeline, uses vs run, job ordering, and where to read logs.


GitHub Actions looks like a wall of YAML the first time you meet it, and most examples throw twenty features at you before the core idea lands. But the whole system is one sentence: something happens in your repo, and that event triggers a workflow, which runs one or more jobs on fresh machines, each job being a list of steps executed in order. Once that sentence is in your head, triggers, jobs, runners, and actions stop being trivia and become obvious pieces of the same machine. This guide builds that mental model, then gives you a first working pipeline and the handful of concepts you actually use every day.

The one idea: an event triggers a workflow

GitHub Actions is CI/CD and automation that lives right next to your code, inside the repo, defined in files you commit. There is no separate server to configure and no plugin marketplace to wrestle with before you can run a test. You describe what should happen and when, GitHub gives you the machines to run it, and the results show up on your commits and pull requests.

The mental model is a chain of four nouns. An event happens (someone pushes a commit, opens a pull request, or a schedule fires). That event triggers a workflow (a YAML file in your repo). The workflow contains jobs, and each job runs on a fresh runner (a clean virtual machine). Inside a job is a list of steps, run top to bottom, where each step either runs a shell command or uses a prebuilt action.

Read that as one flow: event -> workflow -> jobs (on runners) -> steps. Everything else in GitHub Actions - caching, secrets, matrices, conditionals - is detail layered onto that spine. Hold onto it; the rest of this guide is that sentence expanded.

The object model

Each noun in the chain maps to something concrete you can point at.

  • Workflow - a YAML file in .github/workflows/. Any file ending in .yml or .yaml there is a workflow. A repo can have many; each runs independently. The filename does not matter, but the top-level name: is what shows up in the UI.
  • Event - what triggers a workflow, declared under on:. Common ones are push and pull_request, but there are dozens (schedule for cron, workflow_dispatch for a manual button, release, issues, and more).
  • Job - a named unit of work that runs on one runner. A workflow can have several jobs, and by default they run in parallel.
  • Runner - the machine a job runs on, chosen with runs-on:. GitHub hosts runners for you (ubuntu-latest, windows-latest, macos-latest); you can also register your own self-hosted runners. Every job gets a fresh, clean runner - nothing carries over from a previous run unless you explicitly save it.
  • Step - a single item in a job's steps: list, run in order. A step is either a run: (a shell command) or a uses: (a prebuilt action). If a step fails, the rest of that job stops by default.
  • Action - a reusable, packaged unit you pull in with uses:. Actions are how you avoid reinventing common tasks; actions/checkout clones your repo, actions/setup-node installs Node, and thousands more live on the Marketplace.

The whole hierarchy nests cleanly: a workflow has jobs, a job has steps, a step is one command or one action. Keep that nesting in mind and the indentation in the YAML stops being guesswork.

Your first working workflow

Here is a complete, real workflow. Drop it at .github/workflows/ci.yml, commit it, and it runs on every push and every pull request. It checks out your code, installs Node, and runs the tests.

name: CI

on:
  push:
  pull_request:

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

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

      - name: Install dependencies
        run: npm ci

      - name: Run tests
        run: npm test

Walk it top to bottom against the mental model. name: CI is the label in the Actions tab. on: lists the events - here a push to any branch and any pull_request. jobs: has one job called test. That job's runs-on: ubuntu-latest asks GitHub for a fresh Ubuntu runner. Then steps: runs four things in order: check out the repo, install Node 20, install dependencies, run tests. If npm test exits non-zero, the step fails, the job fails, and the commit gets a red X.

The first step deserves a note. A fresh runner does not have your code on it - it is a blank machine. actions/checkout@v4 is what clones your repository onto the runner. Forgetting it is the single most common beginner mistake: the job starts, immediately can't find your files, and fails. Almost every workflow starts with checkout.

uses vs run: the two kinds of step

Every step is one of exactly two things, and the distinction is worth internalizing early.

  • run: executes a shell command on the runner, exactly as if you typed it in a terminal. run: npm test, run: make build, run: ./deploy.sh. Multi-line commands use the YAML block form. This is for your own project's commands.
  • uses: pulls in a prebuilt action - someone else's packaged step - and often passes it inputs with with:. uses: actions/checkout@v4 runs the checkout action; uses: actions/setup-node@v4 with with: { node-version: "20" } installs a specific Node version.
steps:
  - uses: actions/setup-node@v4   # an action: reusable, configured via `with`
    with:
      node-version: "20"
  - run: npm ci                   # a shell command, run as-is on the runner
  - run: |                        # multi-line shell
      echo "building..."
      npm run build

The rule of thumb: reach for a uses: action when the task is generic and someone has already solved it (checking out code, setting up a language, uploading artifacts, logging into a registry). Use run: for anything specific to your project. Pin actions to a version tag (@v4) so a surprise update doesn't break your pipeline.

Jobs run in parallel: needs for ordering

This trips people up, so make it explicit: jobs run in parallel by default. If your workflow has three jobs, GitHub grabs three runners and starts all three at once. They do not share a filesystem and cannot see each other's work, because each is a separate clean machine.

That parallelism is the point when jobs are independent (lint, test, and type-check have no reason to wait for each other). But when one job genuinely depends on another - you must build before you deploy - you enforce order with needs:. A job with needs: build will not start until the build job finishes successfully.

jobs:
  lint:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - run: npm ci
      - run: npm run lint

  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - run: npm ci
      - run: npm test

  deploy:
    needs: [lint, test]        # waits for BOTH to pass before starting
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - run: ./deploy.sh

Here lint and test run at the same time, and deploy only starts once both have passed. needs: is how you turn a flat set of parallel jobs into a pipeline with stages. Because each job is a fresh runner with nothing shared, when a later job needs a file that an earlier job produced, you pass it along explicitly - typically with the actions/upload-artifact and actions/download-artifact actions.

Where logs and status appear

Once a workflow runs, you read the results in a few predictable places.

  • The Actions tab in your repo lists every workflow run, newest first, with its status (a green check, a red X, or a yellow dot for in-progress). Click a run to see its jobs; click a job to see each step's live, expandable log output. This is where you go to read why something failed - the failing step is expanded and highlighted.
  • On the commit and in the pull request, the same status shows up inline. Every commit gets a check mark or an X next to it. A pull request shows its checks at the bottom, and you can require them to pass before merging (branch protection) so red builds cannot merge.
  • Notifications land in your GitHub feed and email when a run you triggered fails, so you find out without watching the tab.

The debugging habit is simple: when a build goes red, open the Actions tab, click the failed run, click the failed job, and expand the failed step. The log tells you exactly which command exited non-zero and what it printed on the way out. Because runners are fresh every time, a failure is reproducible - there is no leftover state from a previous run muddying the result.

The shape of it

GitHub Actions is one sentence wearing different hats: an event in your repo triggers a workflow, which runs jobs on fresh runners, each job a list of steps that either run: a command or uses: an action. Jobs run in parallel unless you chain them with needs:. Checkout is almost always your first step because runners start blank. Results show up on your commits, in pull requests, and in the Actions tab, where the failing step's log tells you what broke. Internalize that flow and the YAML stops being intimidating and becomes a system you can reason about.

From here, the sibling guides go deeper. Workflow syntax covers the full YAML - triggers, contexts, expressions, matrices, and conditionals. CI pipelines builds real build-test-deploy pipelines with caching and artifacts. Secrets and security covers handling credentials, permissions, and locking down what your workflows can do.