GitHub Actions Architecture: Events, Runners, Jobs, and Steps

Every GitHub Actions run begins as a reaction to something that happened in your repository: a push, a pull request, a schedule tick, or a signal from another workflow. GitHub matches that event against every workflow file in .github/workflows, provisions a runner for each job that fires, and then executes your steps in order inside that runner. This lesson goes past the first workflow you already wrote and looks at the architecture underneath it: how events are filtered, what a runner actually is, how jobs relate to one another, and how steps share state inside a job. Understanding this hierarchy turns YAML you copy into YAML you can design.

Overview: How the Pieces Fit Together

GitHub Actions is built from four nested concepts. An event is something GitHub detects: a push, a pull request being opened, a cron schedule firing, a manual workflow_dispatch click, or another workflow finishing via workflow_run. Every event carries a JSON payload that your steps can read through the github.event context, such as github.event.pull_request.number.

A workflow is one YAML file matched against that event by its on: block. A workflow can declare one or more jobs. Each job is scheduled onto its own runner and, by default, jobs in the same workflow start in parallel and know nothing about each other’s filesystem or environment variables. If job B depends on job A finishing first, you say so explicitly with needs; only then can B read A’s declared outputs.

Inside a job, steps run sequentially on the same runner, sharing its filesystem, installed tools, and any environment variables or files a previous step wrote. A step is either uses:, which runs a packaged action (someone else’s or your own reusable code), or run:, which executes shell commands directly.

The runner is the machine that actually executes a job. GitHub-hosted runners (ubuntu-latest, windows-latest, macos-latest) are fresh, ephemeral virtual machines: GitHub boots one, gives it to your job, and destroys it afterward, so nothing persists between runs unless you explicitly cache or upload it. Self-hosted runners are machines you register yourself; they persist between jobs, which makes them faster and cheaper for large workloads but means you are responsible for patching them, isolating them, and — critically — deciding whether they should ever execute code from a pull request you do not control.

Syntax: The Shape of a Workflow

Most of a workflow’s architecture lives in a small set of keys. The table below is the map you will use for the rest of this course.

Key Level Purpose
on workflow Which events trigger this workflow, with optional branch, tag, and path filters
permissions workflow or job The scopes granted to the automatic GITHUB_TOKEN for this run
concurrency workflow or job Groups runs so a newer one can cancel a stale in-progress one
jobs.<id>.runs-on job Which runner label executes this job
jobs.<id>.needs job Other job IDs that must succeed first; also exposes their outputs
jobs.<id>.strategy.matrix job Fans one job definition out into multiple parallel runs with different inputs
jobs.<id>.outputs job Values a job publishes for jobs that need it
steps[].uses / steps[].run step Run a packaged action or a raw shell command

Values wrapped in ${{ }} are expressions evaluated against contexts such as github, needs, matrix, secrets, and env. Expressions are how a step in a matrix job reads its own variant, or how a downstream job reads an upstream job’s output.

Examples

Example 1: a standard CI trigger. This workflow reacts to pushes and pull requests against main, skips documentation-only pushes, declares the minimum token scope it needs, and cancels a stale run when a newer commit lands on the same ref.

name: CI

on:
  push:
    branches: [main]
    paths-ignore:
      - '**.md'
  pull_request:
    branches: [main]

permissions:
  contents: read

concurrency:
  group: ci-${{ github.workflow }}-${{ github.ref }}
  cancel-in-progress: true

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

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

      - name: Install dependencies
        run: npm ci

      - name: Run tests
        run: npm test

Expected behavior: every push to main that touches a non-markdown file, and every pull request targeting main, queues one test job on a fresh ubuntu-latest VM. If a second push lands on the same branch before the first run finishes, the concurrency group cancels the older, now-stale run instead of letting both finish. The job fails the moment npm ci or npm test returns a non-zero exit code.

Example 2: a matrix build with a dependent job. This workflow fans one job out across several Node.js versions, then waits for all of them before summarizing the result.

name: Build and Report

on:
  push:
    branches: [main]

permissions:
  contents: read

jobs:
  build:
    runs-on: ubuntu-latest
    strategy:
      fail-fast: false
      matrix:
        node-version: [18, 20, 22]
    outputs:
      artifact-name: build-${{ github.sha }}
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: ${{ matrix.node-version }}
      - run: npm ci
      - run: npm run build

  summarize:
    needs: build
    runs-on: ubuntu-latest
    steps:
      - name: Report artifact name
        run: echo "Build artifact ${{ needs.build.outputs.artifact-name }} is ready"

Expected behavior: build becomes three parallel runners, one per Node.js version. fail-fast: false means a failure on Node 18 does not cancel the Node 20 or 22 runs. summarize only starts once every matrix instance of build has finished, and only if none of them failed; it then prints a line like Build artifact build-<sha> is ready. A subtlety worth remembering: when a matrixed job declares outputs, the value that survives is whichever matrix instance happened to finish last, not a merge of all of them — do not rely on matrix job outputs carrying per-variant data downstream.

Example 3: reading the event payload safely. This workflow reacts to pull request activity and uses the event payload to label the PR by size.

name: Label Triage

on:
  pull_request:
    types: [opened, synchronize, reopened]

permissions:
  pull-requests: write
  contents: read

jobs:
  label:
    runs-on: ubuntu-latest
    steps:
      - name: Show event details
        run: |
          echo "PR number: ${{ github.event.pull_request.number }}"
          echo "Head SHA: ${{ github.event.pull_request.head.sha }}"

      - name: Add size label
        uses: actions/github-script@v7
        with:
          script: |
            const changed = context.payload.pull_request.changed_files;
            const label = changed > 30 ? 'size/large' : 'size/small';
            await github.rest.issues.addLabels({
              owner: context.repo.owner,
              repo: context.repo.repo,
              issue_number: context.payload.pull_request.number,
              labels: [label]
            });

Expected behavior: whenever a pull request is opened, updated, or reopened, the job prints the PR number and head commit SHA, then calls the GitHub API to add a size/large or size/small label. Because the trigger is pull_request, not pull_request_target, a PR opened from a fork automatically runs with a read-only GITHUB_TOKEN and no access to repository secrets — GitHub enforces that regardless of what the permissions block asks for, so a labeling step attempted from a fork PR would simply fail rather than leak anything.

Step by Step: Tracing an Execution

Walking through Example 1 for a single push clarifies how the layers connect:

  1. A developer pushes a commit to main.
  2. GitHub evaluates every workflow’s on: block against the push event; this workflow matches because the branch is main and at least one changed file is not markdown.
  3. The run is queued under its concurrency group; if an older run for the same branch is still in progress, GitHub cancels it.
  4. GitHub provisions a fresh ubuntu-latest virtual machine for the test job.
  5. Steps execute in order on that one VM: checkout populates the filesystem, setup-node installs the toolchain and restores the npm cache, then npm ci and npm test run against that shared filesystem.
  6. The job’s status becomes success or failure based on whether every step exited with code 0.
  7. The VM is destroyed. Nothing from it persists unless a step explicitly uploaded an artifact or wrote to a cache.

Common Mistakes

Mistake 1: leaving permissions unset and trusting the default. Without an explicit permissions: block, the automatic GITHUB_TOKEN falls back to whatever your organization or repository has configured as the default — in many repositories that is broad read/write access to the whole repository, far more than a build-and-deploy job needs.

# Bad: no permissions declared, token scope is whatever the org default is
on:
  push:
    branches: [main]

jobs:
  deploy:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - run: ./deploy.sh
# Better: minimal workflow-level scope, narrow job-level escalation only where needed
name: Deploy

on:
  push:
    branches: [main]

permissions:
  contents: read

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

  deploy:
    needs: test
    runs-on: ubuntu-latest
    permissions:
      contents: read
      deployments: write
    environment: production
    steps:
      - uses: actions/checkout@v4
      - run: ./deploy.sh

Mistake 2: assuming jobs run in the order they are written. Steps inside a job are sequential, but jobs are not — without needs, GitHub starts every job in a workflow as soon as its runner is available.

# Bad: deploy can start, and even finish, before test has run at all
jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - run: npm test
  deploy:
    runs-on: ubuntu-latest
    steps:
      - run: ./deploy.sh
# Fixed: deploy waits for test to succeed before it is even scheduled
jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - run: npm test
  deploy:
    needs: test
    runs-on: ubuntu-latest
    steps:
      - run: ./deploy.sh

Mistake 3: using pull_request_target to run untrusted code. pull_request_target runs with the base repository’s token permissions and secrets, even for forked pull requests — the opposite safety model from pull_request. Checking out the fork’s head commit and then running its scripts under that privileged token hands an attacker a path to your secrets.

# Dangerous: checks out untrusted fork code, then runs it with a privileged, secret-bearing token
on: pull_request_target

jobs:
  build:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
        with:
          ref: ${{ github.event.pull_request.head.sha }}
      - run: npm ci
      - run: npm test
        env:
          DEPLOY_TOKEN: ${{ secrets.DEPLOY_TOKEN }}
# Safer: pull_request_target is only used for trusted metadata actions, never to run fork code
on: pull_request_target

permissions:
  pull-requests: write

jobs:
  label:
    runs-on: ubuntu-latest
    steps:
      - name: Label without checking out fork code
        uses: actions/github-script@v7
        with:
          script: |
            await github.rest.issues.addLabels({
              owner: context.repo.owner,
              repo: context.repo.repo,
              issue_number: context.payload.pull_request.number,
              labels: ['triage']
            });

Best Practices

  • Pin third-party actions to a full commit SHA when the action touches secrets or deploy credentials; a SHA cannot be silently moved by the action’s maintainer, though you must manually bump it to receive fixes. For lower-risk actions, a maintained major-version tag (@v4) is an acceptable trade-off between security and upkeep.
  • Declare the narrowest permissions: block that the workflow needs, and escalate further only inside the specific job that needs it, as in the deploy example above.
  • Use concurrency groups on CI and deploy workflows so superseded runs are cancelled instead of racing to completion.
  • Never combine pull_request_target with checking out or executing a fork’s head commit; reserve it for read-metadata operations like labeling or commenting.
  • Treat self-hosted runners attached to a public repository as a shared attack surface — a fork’s workflow run could reach that machine. Restrict self-hosted runners to private repositories or require maintainer approval before workflows from first-time contributors run.
  • Gate deploy jobs behind a protected environment: with required reviewers, and make sure the job depends on your test and security-scan jobs via needs so a broken build can never reach production.
  • Prefer an image digest over a mutable tag when a step pulls a container image; a tag can be repointed to different content later, a digest cannot.

Practice Exercises

  1. Take Example 1 and add a paths: filter under push so the workflow only triggers when files under src/ or package.json change. Confirm that a commit touching only README.md does not queue a run.
  2. Extend Example 2 with a third job named deploy that declares needs: [build, summarize] and only runs when github.ref == 'refs/heads/main'. Trace what happens if one matrix build fails.
  3. Open a workflow file from an earlier lesson in this course. List every third-party uses: action, note whether each is pinned to a tag or a commit SHA, and write down the workflow’s current permissions: block — decide whether it is broader than the jobs actually require.

Summary

A GitHub Actions run is a chain of scoped decisions: an event is matched, a workflow is selected, jobs are scheduled onto runners in parallel unless you sequence them with needs, and steps execute one after another on the same runner’s filesystem. GitHub-hosted runners are ephemeral and disposable; self-hosted runners persist and must be secured, especially against forked pull requests. Explicit permissions, careful action pinning, and a hard line against running untrusted code under pull_request_target are what turn this architecture into a pipeline you can trust with production credentials — the deploy, security, and observability lessons ahead all build on this event-to-step model.