Free lesson · GitHub Actions: Workflows, Events and Jobs

Your first workflow, and the vocabulary

GitHub Actions runs code in response to events in your repository. The moving parts are few; most of the difficulty people hit is vocabulary, so we will settle that first.

The hierarchy

Workflow          a YAML file in .github/workflows/
 └── triggered by an event (push, pull_request, schedule…)
      └── Job     runs on one runner; jobs are parallel by default
           └── Step   a command, or a reusable Action
                └── runs:  a shell command
                └── uses:  a packaged action

Two facts carry most of the consequences:

  • Each job gets a fresh machine. Nothing on disk survives between jobs.
  • Steps within a job share that machine, sequentially.

A complete first workflow

# .github/workflows/ci.yml
name: CI

on:
  push:
    branches: [main]
  pull_request:

jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - uses: actions/setup-node@v4
        with:
          node-version: '20'
          cache: npm

      - run: npm ci
      - run: npm test

Commit that to .github/workflows/ci.yml, push, and it runs. Line by line:

  • name — what shows in the Actions tab. Optional.
  • on — which events trigger it.
  • jobs.testtest is an id you choose; other jobs refer to it.
  • runs-on — which runner. ubuntu-latest is the cheapest and fastest.
  • uses: actions/checkout@v4 — clone the repo. Without this the runner has no code.
  • with: — inputs to the action.
  • run: — a shell command on the runner.
The three first-workflow mistakes
  1. Forgetting actions/checkout. "npm: no such file or directory" — because the directory is empty.
  2. Expecting files to persist between jobs. They do not. Use artifacts or run in one job.
  3. Putting the workflow on a feature branch and expecting it to run for main. The workflow file must exist on the branch the event relates to.

Events

on:
  push:
    branches: [main, 'release/**']
    paths: ['src/**', 'package.json']
    tags: ['v*']

  pull_request:
    types: [opened, synchronize, reopened]
    branches: [main]

  schedule:
    - cron: '0 3 * * 1'          # 03:00 UTC every Monday

  workflow_dispatch:              # manual, with inputs
    inputs:
      environment:
        type: choice
        options: [staging, production]
        default: staging

  release:
    types: [published]

  issue_comment:
    types: [created]

Filters within one event are ANDed: branches and paths must both match.

push and pull_request differ meaningfully

push tests the commit as pushed. pull_request tests a simulated merge of your branch into the base — which is usually what you want, since that is what will actually land. A branch in the same repository fires both, so unfiltered workflows run twice per push. Restricting push to branches: [main] is the usual fix.

Steps: run and uses

steps:
  # A shell command
  - name: Run tests
    run: npm test

  # Multiple lines
  - name: Build and verify
    run: |
      npm run build
      ls -la dist/
      test -f dist/index.js

  # A different shell
  - run: Get-ChildItem
    shell: pwsh

  # A packaged action
  - uses: actions/setup-python@v5
    with:
      python-version: '3.12'

  # An action from any repository, or a Docker image
  - uses: my-org/my-action@v1
  - uses: docker://alpine:3.19

Contexts and expressions

${{ }} evaluates an expression. The contexts you will use constantly:

${{ github.ref }}              # refs/heads/main
${{ github.ref_name }}         # main
${{ github.sha }}              # the commit
${{ github.event_name }}       # push, pull_request…
${{ github.actor }}            # who triggered it
${{ github.repository }}       # owner/name
${{ github.run_number }}       # incrementing per workflow

${{ secrets.MY_SECRET }}
${{ vars.MY_VARIABLE }}        # non-secret config
${{ env.MY_ENV }}
${{ matrix.version }}
${{ needs.build.outputs.tag }}
${{ steps.meta.outputs.value }}
${{ runner.os }}               # Linux, Windows, macOS

Useful functions: contains(), startsWith(), endsWith(), format(), join(), toJSON(), fromJSON(), hashFiles().

Conditionals

jobs:
  deploy:
    if: github.ref == 'refs/heads/main' && github.event_name == 'push'
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - name: Upload logs even if the build failed
        if: always()
        uses: actions/upload-artifact@v4
        with:
          name: logs
          path: logs/

      - name: Notify only on failure
        if: failure()
        run: ./scripts/alert.sh

Status functions: success() (the implicit default), failure(), always(), cancelled().

if: does not take ${{ }}

The if: key is already an expression context, so write if: github.ref == 'refs/heads/main', not if: ${{ github.ref == … }}. The braces form usually works but is redundant, and it silently misbehaves in some nested cases.

This is one lesson of 52

Git & GitHub Actions: Version Control and CI/CD in Depth continues from here — 5 lessons are free to read like this one, and the rest come with the course. Enrolled readers also get an AI tutor that has read the lesson they are on.

See the full course