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:
- A developer pushes a commit to
main. - GitHub evaluates every workflow’s
on:block against the push event; this workflow matches because the branch ismainand at least one changed file is not markdown. - The run is queued under its
concurrencygroup; if an older run for the same branch is still in progress, GitHub cancels it. - GitHub provisions a fresh
ubuntu-latestvirtual machine for thetestjob. - Steps execute in order on that one VM: checkout populates the filesystem, setup-node installs the toolchain and restores the npm cache, then
npm ciandnpm testrun against that shared filesystem. - The job’s status becomes success or failure based on whether every step exited with code 0.
- 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
concurrencygroups on CI and deploy workflows so superseded runs are cancelled instead of racing to completion. - Never combine
pull_request_targetwith 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 vianeedsso 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
- Take Example 1 and add a
paths:filter underpushso the workflow only triggers when files undersrc/orpackage.jsonchange. Confirm that a commit touching onlyREADME.mddoes not queue a run. - Extend Example 2 with a third job named
deploythat declaresneeds: [build, summarize]and only runs whengithub.ref == 'refs/heads/main'. Trace what happens if one matrix build fails. - 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 currentpermissions: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.
