Introduction to GitHub Actions

GitHub Actions is GitHub’s built-in automation platform: it lets you run scripts automatically in response to events in your repository, such as pushing code, opening a pull request, or creating a release. Instead of manually running your test suite or deployment scripts on your own machine, you describe the steps once in a YAML file, and GitHub runs them for you on a fresh virtual machine every time the event happens. This is the foundation of continuous integration (CI) and continuous deployment (CD) — automatically testing and shipping code so mistakes are caught early and releases stop depending on someone remembering to run a checklist by hand.

Overview / How it works

Everything in GitHub Actions starts with a workflow: a YAML file stored in the .github/workflows/ directory of your repository. GitHub watches your repository for events — a push, a pull_request, a scheduled time, someone clicking a button in the UI — and when an event matches what a workflow file listens for, GitHub spins up a fresh, temporary virtual machine (called a runner) and executes the workflow on it. Nothing persists between runs by default; each run starts from a clean image with your repository checked out fresh.

A workflow is made of one or more jobs. Each job runs on its own runner (so jobs are isolated from each other unless you explicitly pass data between them) and, unless you configure dependencies with needs, jobs run in parallel. Inside a job is a sequence of steps, executed in order on the same runner. A step is either a shell command (via run) or a reusable action (via uses) — a packaged, shareable unit of automation that someone else (often GitHub itself) has already written, such as actions/checkout, which fetches your repository’s code onto the runner, or actions/setup-node, which installs a specific Node.js version.

It helps to connect this back to Git’s own model: a workflow run is triggered by a change to the ref graph you already know — a new commit landing on a branch, a tag being pushed, or a pull request updating its head. GitHub Actions does not modify your Git history at all by default; it simply reacts to it. Actions that do write back to the repository (auto-formatting bots, changelog generators) do so by making ordinary commits and pushes through the runner’s own checkout, using the same object model — new blobs, a new tree, a new commit, and a moved branch pointer — as any other commit.

Workflow runs, their logs, and their pass/fail status show up in your repository’s Actions tab, and a summarized status also appears directly on commits and pull requests as green checkmarks or red X marks, which is how most teams first encounter Actions: as required CI checks that must pass before a pull request can be merged.

Syntax

A workflow file lives at .github/workflows/<name>.yml and has this general shape:

name: <workflow name>
on: <event or list of events>
jobs:
  <job-id>:
    runs-on: <runner image>
    steps:
      - uses: <action-name>@<version>
      - run: <shell command>
Key Meaning
name Display name for the workflow, shown in the Actions tab. Optional but recommended.
on The event(s) that trigger the workflow: push, pull_request, schedule, workflow_dispatch (manual trigger button), release, and many more. Can be filtered by branch or path.
jobs A map of job IDs to job definitions. Jobs run in parallel unless linked with needs.
runs-on The virtual machine image for the job, e.g. ubuntu-latest, windows-latest, macos-latest.
steps An ordered list of actions or shell commands executed on that runner.
uses Runs a reusable action, pinned to a version, e.g. actions/checkout@v4.
run Runs a raw shell command on the runner.
with Passes input parameters to an action used via uses.
env Sets environment variables available to a step, job, or the whole workflow.

Examples

Example 1: A minimal workflow

Add the workflow file and push it, just like any other tracked file:

mkdir -p .github/workflows
touch .github/workflows/hello.yml
git add .github/workflows/hello.yml
git commit -m "ci: add hello world workflow"
git push origin main

With this content inside hello.yml:

name: Hello World
on: push
jobs:
  greet:
    runs-on: ubuntu-latest
    steps:
      - run: echo "Hello from GitHub Actions!"

Output (in the Actions tab log for the greet job):

Run echo "Hello from GitHub Actions!"
Hello from GitHub Actions!

The moment this file lands on the main branch, GitHub notices the push event, spins up an Ubuntu runner, and executes the single run step. You did not install anything or click any buttons — the file itself is the configuration.

Example 2: Checking out code and running tests

Real workflows almost always start by checking out the repository, then installing dependencies and running a real test command:

name: CI
on:
  push:
    branches: [main]
  pull_request:
    branches: [main]
jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: "20"
      - run: npm ci
      - run: npm test

Output (abridged):

Run actions/checkout@v4
  ...checking out github.com/you/repo@a1b2c3d...
Run actions/setup-node@v4
  Node.js 20.11.1 installed
Run npm ci
  added 412 packages in 8s
Run npm test
  PASS  src/app.test.js
  Tests: 12 passed, 12 total

This workflow triggers on pushes to main and on any pull request targeting main. actions/checkout@v4 clones your repository’s exact commit onto the runner (this is why it must run first — without it, there is no code on the runner at all), then actions/setup-node@v4 installs the requested Node.js version, and the two run steps install dependencies and execute the test suite. If npm test exits non-zero, the job is marked failed and that failure shows up as a red X on the pull request.

Example 3: A matrix build across multiple versions

name: CI Matrix
on: [push, pull_request]
jobs:
  test:
    runs-on: ubuntu-latest
    strategy:
      matrix:
        node-version: [18, 20, 22]
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: ${{ matrix.node-version }}
      - run: npm ci
      - run: npm test

Output: the Actions tab shows three separate job runs — test (18), test (20), test (22) — each on its own runner, all in parallel.

The strategy.matrix key tells GitHub to run the same job once per combination of listed values, substituting ${{ matrix.node-version }} in each run. This is how projects confirm their code works across multiple language versions, operating systems, or dependency versions without hand-writing a separate job for each.

How it works step by step

  1. An event happens in the repository (you git push a commit, or open a pull request).
  2. GitHub scans every file in .github/workflows/ on the relevant commit and finds any whose on key matches the event.
  3. For each matching workflow, GitHub creates one workflow run and, for every job inside it, provisions a fresh runner virtual machine from the requested image.
  4. On each runner, steps execute top to bottom in the same shell session — environment variables and the working directory persist between steps in a job, but nothing carries over between separate jobs unless you explicitly upload/download artifacts or pass outputs.
  5. Each step’s exit code is checked; a non-zero exit fails the step, which by default fails the job and stops remaining steps in it.
  6. When all jobs finish, GitHub reports the overall run status back to the commit and, if applicable, the pull request’s checks list.
  7. The runner and everything on its disk is destroyed. The only durable results are the logs, any artifacts you explicitly uploaded, and the pass/fail status.

Common Mistakes

Mistake: forgetting actions/checkout

jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - run: npm test

Without a checkout step, the runner starts as an empty machine with no copy of your repository, so npm test fails immediately because there is no package.json to find. Always add uses: actions/checkout@v4 as the first step of any job that needs your repository’s files.

Mistake: bad YAML indentation

jobs:
  test:
  runs-on: ubuntu-latest
    steps:
    - run: npm test

YAML is whitespace-sensitive: runs-on here is indented at the same level as test instead of one level deeper, so it’s parsed as a sibling key of the job rather than a property of it. GitHub will reject the file with a parsing error and the workflow never runs at all. Keep indentation consistent (two spaces per level is the common convention) and use an editor with YAML linting.

Mistake: assuming secrets are safe to hardcode

steps:
  - run: curl -H "Authorization: token ghp_aBcDeFgHiJkLmNoPqRsT" https://api.example.com

Never write a real token directly into a workflow file — it becomes permanently visible in your repository’s history to anyone with read access. Store it as a repository secret (Settings → Secrets and variables → Actions) and reference it as ${{ secrets.API_TOKEN }} instead.

Mistake: not pinning action versions

Using uses: actions/checkout@main instead of a versioned tag like @v4 means your workflow’s behavior can change without warning whenever the action’s maintainers push new commits to their default branch. Pin to a released version (or a specific commit SHA for maximum reproducibility).

Best Practices

  • Keep workflow files small and focused — one workflow per concern (CI tests, linting, deployment) rather than one giant file doing everything.
  • Pin third-party actions to a specific version tag or commit SHA, not a branch name, so a workflow’s behavior doesn’t shift under you.
  • Store credentials only in GitHub Secrets, never in the workflow file or committed code.
  • Use pull_request triggers to run CI on proposed changes before they merge, and branch protection rules to require those checks to pass.
  • Cache dependencies (e.g. with actions/cache) for slow install steps like npm ci to speed up repeated runs.
  • Give workflows and jobs clear, descriptive names so the Actions tab and PR checks list stay readable as you add more of them.
  • Start with workflow_dispatch as an additional trigger while developing a new workflow, so you can run it manually without needing a real push or PR each time.

Practice Exercises

  • Create a new repository, add a .github/workflows/ci.yml file that triggers on push and simply runs echo "CI ran", then push it and confirm the run appears (and succeeds) in the Actions tab.
  • Extend that workflow to check out the repository with actions/checkout@v4 and run a real command against a file in your repo, such as cat README.md. Confirm the step’s log shows the file’s actual contents.
  • Open a pull request against your repository and add a pull_request trigger to the workflow so it also runs on the PR. Verify the check appears directly on the pull request page, not just in the Actions tab.

Summary

  • GitHub Actions runs automation in response to repository events, defined in YAML files under .github/workflows/.
  • A workflow contains one or more jobs; each job runs on its own fresh runner and contains an ordered list of steps.
  • Steps either run shell commands (run) or invoke reusable actions (uses), such as actions/checkout to fetch your code.
  • Workflow runs and their pass/fail status appear in the Actions tab and directly on commits and pull requests.
  • Pin action versions, keep secrets in GitHub Secrets, and use pull_request triggers with branch protection to gate merges on passing checks.