Developer Tooling

GitHub Actions, one layer at a time: from six lines to a hardened pipeline

Start with the smallest workflow that does something useful, then add each concept only when the previous version breaks. By the end you’ve built — and actually understood — a real pipeline.

Primer·software & platform engineers·

Scope: verified against GitHub’s docs, July 2026 — the OS billing multipliers (Linux ×1 / Windows ×2 / macOS ×10), the usage limits below, and the Node 24 action runtime (Node 20 is deprecated). Larger-runner pricing still moves; check the pricing page for exact rates.

The docs hand you every knob at once, which is why GitHub Actions feels bigger than it is. So we’ll do the opposite: build one pipeline from six lines up, and let each new idea show up exactly when the current version hits a wall — the same order you’d hit them for real. Watch the same ci.yml grow, and the concepts arrive already motivated.

TL;DR — the walls, in the order you hit them

  • Six lines is already a real pipeline. on: push, one job, pytest. Everything else is a layer you add when you hit a wall — not up front.
  • Wall 1 — a second job can’t see the first’s files. Each job is a fresh, isolated VM; data crosses only through artifacts, cache, and outputs.
  • Wall 2 — you’re copy-pasting setup. The action is the reusable unit: composite actions share steps, reusable workflows share a whole pipeline.
  • Wall 3 — it has to deploy without leaking a key. Scope GITHUB_TOKEN to read-only, then use OIDC so no static cloud keys are ever stored.
  • Wall 4 — a forked PR is a stranger’s code. Fork PRs get no secrets by design; the three real attacks are script injection, pull_request_target, and unpinned actions.
  • Wall 5 — it’s slow and it costs. matrix / concurrency / cache, and the OS multiplier: Linux ×1 / Windows ×2 / macOS ×10.
Part 1Build the pipeline

Start here — six lines that actually work

You want your tests to run when you push. That’s the entire ambition, and this is the entire solution:

# .github/workflows/ci.yml  —  the whole thing, to start
on: [push]                       # WHEN: run on every push
jobs:
  test:                          # WHAT: one job...
    runs-on: ubuntu-latest       #   ...on a fresh Ubuntu VM
    steps:                       #   ...running these commands in order
      - uses: actions/checkout@v4            # a prebuilt action: clone the repo
      - run: pip install -r requirements.txt
      - run: pytest

That is a real, working pipeline. Three keys carry the whole model: on: is when (the event), jobs: is what runs and where (each job gets its own machine), and steps: are the commands, in order — either a run: shell line or a uses: that pulls in a prebuilt action (here, checkout to clone your code). Push a commit and GitHub runs it, then reports pass or fail right on the commit and any PR.

Before we add anything, it’s worth seeing the full arc those six lines set in motion — from the event that fires to the result that lands back in the PR. Every layer after this just refines one part of this loop.

The run lifecycle — what a trigger sets in motion, end to endDeveloperpushes a committo the PR branchGitHubEvent: pull_requestmatched against on: filtersWorkflow runqueued -> in progressChecks API+ GITHUB_TOKEN (job-scoped)Runner (ephemeral VM / self-hosted)Checkout + cache + toolchainSteps run: build / test / lintrun: commands + uses: actionsJob completesannotations · summary · outputs · conclusionPull request conversationChecks summarygreen ✓ / red ✗ · required checks gate mergeInline annotationsfile + line, on the diffBot comment (optional)posted via GITHUB_TOKEN123456789
  1. A developer pushes a commit to the PR branch — the pull_request (or push) event fires.
  2. GitHub matches the event against each workflow’s on: filters (event, branches, paths) and creates a workflow run; a check shows “Queued” on the PR.
  3. GitHub assigns each job to a runner (an ephemeral GitHub-hosted VM by label, or self-hosted) and mints a job-scoped GITHUB_TOKEN.
  4. The runner checks out the code (actions/checkout), restores any cache, and sets up the toolchain.
  5. Steps run in orderrun: commands and uses: actions (build, test, lint); logs stream live to the Checks tab.
  6. Steps emit annotations, a job summary, and outputs; the check run shows “In progress.”
  7. The job completes with a conclusion (success / failure) and GitHub updates the check run.
  8. Results post to the PR conversation: the checks summary, inline annotations on the diff, and any comment a workflow left via the token.
  9. Required checks gate the merge button; the developer reads the result and pushes a fix — which starts the loop again.
Figure 1 · The run lifecycle — from the trigger that fires a workflow to the checks, annotations, and comment that land back in the PR conversation.

The cast of characters — every piece, named (and how many you get)

Before we pile on more layers, here is the whole cast and how it nests — the vocabulary trips people more than the concepts do. A workflow is one .yml file; what other tools call a pipeline, GitHub simply calls a workflow (there is no separate “pipeline” object). An action is not a workflow — it is a single reusable building block that a step pulls in with uses:. And the file you write is distinct from a workflow run, which is one execution of it.

Every piece, and how it nestsRepository— your code + its .github/workflows/ folderWorkflow— one .yml file — what other tools call a "pipeline"Eventpush / pull_request / schedule / manual → fires one workflow run each timeWorkflow run— ONE execution of the workflow (the file is the recipe)Job— runs on its own Runner · jobs run in PARALLEL by defaultRunnerStep— ordered; each one is a run: OR a uses:run: a shell commanduses: → plugs in an ActionActionA reusable, packaged building block(composite · JavaScript · Docker),from a repo or the Marketplace.You plug ONE into a step; it is NOTa whole workflow.WORDS PEOPLE MIX UPWorkflow = PipelineGitHub has no "pipeline" object —your workflow is it.Workflow ≠ Workflow runthe .yml is the recipe; a run is oneexecution.Action ≠ Workflowan action is ONE reusable step; aworkflow orchestrates many.Job vs Stepa job is a machine; a step is onecommand on it.
Figure 2 · The cast, and how it nests — a repository holds workflow files; an event fires a workflow run; a run holds parallel jobs; a job holds ordered steps; a step is a run: command or a uses: that plugs in an action. The confusions that trip people are called out below the diagram.

“How many workflows can I run?”

Three different things are capped, so the answer depends on which you mean:

What you’re countingThe limit
Workflow files in a repoNo hard limit — add as many .yml files as you like
Jobs running at once (across all runs)Capped by plan: Free 20 · Pro 40 · Team 60 · Enterprise 500 (standard runners; larger-runner pools cap at 1,000). Support can raise it.
Jobs in one workflow run256 max (a matrix also tops out at 256)
New workflow runs queued500 per 10 seconds, per repository
One job’s run time6 hours on GitHub-hosted runners (5 days on self-hosted)
One workflow run’s total time35 days including time queued — then it’s cancelled
Reusable-workflow reach4 nesting levels deep, up to 20 unique reusable workflows per run
GITHUB_TOKEN API calls1,000 requests per hour, per repository
So you can keep unlimited workflow files; what actually throttles you is the concurrent-job cap for your plan. Want to force serial execution — one deploy at a time — that’s the concurrency: key from the scaling layer, a choice you make, not a platform limit.

How the YAML keys fit together — and where with goes

A workflow file has a lot of keys, and they feel scattered until you notice the rule: every key belongs to exactly one level — the workflow, a job, or a step — and its level tells you what it’s allowed to do. Read the file as three nested scopes and the keys sort themselves.

The YAML keys — each belongs to exactly one levelAt the WORKFLOW level— top of the filename:a label for the workflowon:WHEN it runs — the trigger event(s)permissions:default GITHUB_TOKEN scopes for all jobsenv:environment variables for every jobconcurrency:cancel or serialize overlapping runsdefaults:default shell / working-directoryjobs:the jobs — each runs in PARALLEL ↓Under each JOB— a job id you name (build:, deploy:)runs-on:which runner (ubuntu-latest, self-hosted…)needs:wait for other jobs to finish firstif:run this job only when a condition holdsstrategy: / matrix:fan the job across combinationsenvironment:deployment gate + environment secretspermissions:override the token scopes for this joboutputs:values that dependent jobs can readtimeout-minutes:kill a stuck jobsteps:the ordered steps ↓Under each STEP— a "-" list itemname:a label for the stepid:so later steps can read its outputsif:run this step only when a condition holdsuses:call an ACTION (or a reusable workflow)with:the INPUTS to that uses: — it pairs with usesrun:OR a shell command (instead of uses:)env:environment variables for this stepshell: / working-directory:how run: executes
Figure 3 · Every keyword belongs to one level — workflow, job, or step — and its level tells you what it can do. with: lives on a uses: step because it passes that action’s inputs; a run: step has no with: (it takes env: instead).

The one that trips everyone is with:. It is the inputs to a uses: action, so it only appears on a uses: step, indented right under it. A run: step has no with: — you give it env: instead. The same pattern holds one level up: when you call a reusable workflow with uses:, you pass its inputs with with: too (and its secrets with secrets:).

LevelThe keys you’ll actually use
Workflow (top of file)name · on · permissions · env · concurrency · defaults · jobs
Job (under a job id)runs-on · needs · if · strategy/matrix · environment · permissions · env · outputs · timeout-minutes · steps
Step (a - item)name · id · if · uses · with · run · env · shell · working-directory · continue-on-error
Two keys blur the levels on purpose: env:, permissions:, and if: can appear at workflow, job, or step level — the innermost one wins. So a workflow-wide permissions: contents: read can be widened by one job that needs more.

“I need lint and a build too” — steps vs jobs

Real CI does more than one thing. Add a lint step and the shape of the problem appears: steps inside a job run one after another on the same machine, so a slow lint blocks your tests, and they can’t use different setups. When two things are independent, you don’t want them queued — you want them at once.

That’s the difference between a step and a job. Split independent work into separate jobs; jobs run in parallel by default, each on its own runner. When you do need order — build after tests pass — you say so with needs:.

jobs:
  lint:                          # a SECOND job — runs in PARALLEL with test
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - run: ruff check .
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - run: pip install -r requirements.txt
      - run: pytest
# Steps inside a job run in ORDER on one machine.
# Separate jobs run AT THE SAME TIME — unless you chain them with `needs:`.

So the hierarchy is just: a workflow wires an event to jobs (parallel machines), and each job runs steps (ordered commands) that call actions. Hold onto “each job is its own machine” — it’s about to bite.

“My deploy can’t find what build made” — jobs are isolated

You add a build job that produces ./dist, then a deploy job to ship it — and deploy fails: dist/ isn’t there. Nothing is broken. This is the single most important thing to internalize about GitHub Actions: every job runs on a brand-new VM, and its filesystem is thrown away when the job ends. Steps in one job share a disk; separate jobs share nothing.

Jobs are isolated VMs — the filesystem is discarded; only three things cross the bridge Job A · build / test (isolated VM) 1 Runner filesystem · /home/runner/work dist/ .venv build.tar Discarded when the job ends 5 Artifacts files: build output 2 Cache deps: keyed by hashFiles 3 Job outputs small string values 4 Job B · deploy (fresh VM) Filesystem starts empty receives only what crossed the bridge
  1. Job A does its work on the runner filesystem.
  2. To hand files to a later job, upload them as artifacts (upload-artifact / download-artifact).
  3. To reuse dependencies, save a cache keyed by a lockfile hash (actions/cache).
  4. To pass a small value, declare a job output and read it via needs.build.outputs.*.
  5. Job A's filesystem is destroyed; Job B starts empty and receives only what crossed the bridge.
Figure 4 · Why your file is gone — jobs are isolated VMs; only artifacts (files), cache (dependencies), and outputs (small values) survive the crossing.

So you move things across the gap deliberately. Files that a later job needs — a built package, a coverage report — go through artifacts (upload-artifact / download-artifact). Dependencies you want to reuse for speed go through a cache. Small string values (a version, an image tag) go through job outputs and are read via needs.build.outputs.*. Nothing else survives the crossing.

  build:
    needs: [lint, test]          # runs only after BOTH lint and test pass
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - run: python -m build                 # produces ./dist
      - uses: actions/upload-artifact@v4      # hand ./dist to a later job
        with: { name: dist, path: dist/ }
  deploy:
    needs: build
    runs-on: ubuntu-latest       # a BRAND-NEW VM — build's ./dist is already gone
    steps:
      - uses: actions/download-artifact@v4    # the only way to get it back
        with: { name: dist }
      - run: ./deploy.sh
Within a single job you still just use files. It’s only the boundary between jobs that’s a fresh machine — that’s the line most “why is my file gone” bugs sit on.

“I’ve pasted the same setup four times” — actions and reuse

By now checkout + setup-python + pip install appears in every job, and a sister repo wants the exact same pipeline. This is where the value compounds — and where two different tools get confused. The reusable unit is the action: the thing uses: calls. Bundle your repeated steps into a composite action and each job shrinks to two lines. To share the whole pipeline across repos, publish a reusable workflow and let callers invoke it by ref.

The reuse taxonomy — a composite action shares steps; a reusable workflow shares a pipeline Step 1 run: a shell command uses: call an action Action · the reusable unit 2 Composite runs.using: composite bundles STEPS shares steps 3 JavaScript runs.using: node24 runs on the runner Docker runs.using: docker isolated, Linux-only Reusable workflow · on: workflow_call — wraps whole JOBS (a pipeline) called by uses: owner/repo/.github/workflows/x.yml@<sha> — shares a pipeline across repos 4
  1. A step is the atom: either a run: shell command or a uses: action.
  2. An action packages logic and is the reusable unit.
  3. Actions come in three kinds — composite (bundles steps), JavaScript (Node on the runner), Docker (isolated container, Linux-only). A composite action shares a step sequence inside one job.
  4. A reusable workflow shares whole jobs — a pipeline — across repos, called by ref or SHA.
Figure 5 · The reuse taxonomy — “composite action” shares steps within a job; “reusable workflow” shares an entire pipeline.
# .github/actions/setup/action.yml — bundle the repeated steps ONCE
name: setup
runs:
  using: composite
  steps:
    - uses: actions/setup-python@v4
      with: { python-version: "3.12", cache: pip }
    - run: pip install -r requirements.txt
      shell: bash                 # composite run: steps MUST set a shell

# then every job shrinks to two lines:
#   - uses: actions/checkout@v4
#   - uses: ./.github/actions/setup
# .github/workflows/reusable-ci.yml — share a WHOLE pipeline across repos
on:
  workflow_call:                  # this makes the workflow callable
    inputs:
      python-version: { type: string, default: "3.12" }
jobs:
  test: { runs-on: ubuntu-latest, steps: [ ... ] }

# a caller in ANY repo runs the whole thing:
# jobs:
#   ci:
#     uses: my-org/ci-templates/.github/workflows/reusable-ci.yml@<sha>
#     with: { python-version: "3.11" }
Rule of thumb: a composite action shares a step sequence inside a job; a reusable workflow shares whole jobs — a pipeline — across repos. Actions also come as JavaScript (runs Node on the runner) and Docker (an isolated container); composite is the one you’ll write most.
Part 2Make it trustworthy

“It has to deploy — without me pasting a cloud key” — the token and OIDC

The deploy job needs credentials, and the instinct is to drop a long-lived AWS access key into repo secrets. That static key is exactly the thing that leaks. GitHub Actions gives you two better answers. First, every run already has an auto-generated GITHUB_TOKEN that expires when the job ends; its power is set by a permissions: block, per scope. Default it to read-only and grant up only where needed — the single highest-leverage hardening move.

Second, for the cloud, use OIDC: the job mints a short-lived token, and a trust policy in your cloud account swaps it for temporary credentials scoped to this repo and branch. No static keys live anywhere.

The trust boundary — the same PR event, three very different positions pull_request (fork) 1 Context: your base repo Token: READ-ONLY Secrets: NONE SAFE A fork PR can run your tests but cannot read secrets or push. This is the default that protects you — don't defeat it. pull_request_target 2 Context: BASE repo (not the PR) Token: READ / WRITE Secrets: AVAILABLE DANGER Runs with write + secrets. If it then checks out the PR head and runs it, attacker code runs with your privileges — a “pwn request.” OIDC (id-token: write) 3 Job mints a short-lived token Exchanged with AWS/Azure/GCP for TEMPORARY cloud creds NO STATIC KEYS A cloud trust policy scoped to repo/branch issues credentials per run. Nothing long-lived sits in your secrets.
  1. pull_request from a fork — read-only token, no secrets: safe by default.
  2. pull_request_target — runs in the base context with a write token and secrets; safe only if it never checks out or runs PR code.
  3. OIDC — the job mints a short-lived, repo-scoped token and swaps it for temporary cloud credentials; no static keys in secrets.
Figure 6 · The trust boundary — the safe fork default, the dangerous pull_request_target shortcut, and OIDC as the modern keyless deploy.
permissions:
  contents: read                 # least privilege for the WHOLE run; grant up per job
jobs:
  deploy:
    permissions:
      contents: read
      id-token: write            # let THIS job mint a short-lived OIDC token
    steps:
      - uses: aws-actions/configure-aws-credentials@v4   # swaps OIDC -> temp creds
        with:
          role-to-assume: arn:aws:iam::123456789012:role/gha-deploy
          aws-region: us-east-1
      - run: ./deploy.sh          # deploys with TEMPORARY creds — nothing stored in secrets

“Don’t auto-ship to prod” — Environments and approvals

Now every merge deploys, and you want a human in the loop for production. Change control is native, and it lives in Environments. An environment (say production) carries its own secrets plus protection rules: required reviewers (a manual approval gate), a wait timer, and branch restrictions. Point the deploy job at it and the job simply pauses until someone approves.

  deploy:
    needs: build
    environment: production       # protection rules apply here:
    runs-on: ubuntu-latest        #   required reviewers · wait timer · branch limits
    steps:
      - run: ./deploy.sh          # this job PAUSES until a reviewer approves
Environment secrets and rules attach to the environment, not the repo — so staging and production can hold different values and different approvers, and a job only sees the one it targets.

“Wait — my CI runs strangers’ code” — the fork trust line

Your workflow runs on pull requests, including from forks — that is, from people you’ve never met. The good news is the default is safe: a pull_request from a fork runs with a read-only token and no secrets, on purpose, so a drive-by PR can’t exfiltrate anything. The bad news is that almost every real compromise is one of three default-looking patterns.

Three ways to get owned — and the one-line fix for each 1 Script injection run: echo "${{ github.event.pull_request.title }}" The PR title is attacker-controlled — it runs as a shell command on your runner. Fix · pass it through env: env: { TITLE: ${{ ...title }} } then reference "$TITLE" in run:. Never inline ${{ }} into a shell command. 2 pull_request_target “pwn request” It has a write token + secrets, then checks out and runs the PR head — attacker code executes with your privileges. Fix · don't Never build or run untrusted PR code under it. Keep untrusted CI on plain pull_request; gate secret work behind review. 3 Unpinned third-party action uses: some/action@v4 # a moveable tag A compromised maintainer re-points the tag — new code ships into your pipeline silently. Fix · SHA-pin uses: some/action@<40-char sha> Pin to a full commit SHA and review each update. Prefer verified/first-party.
  1. Script injection — untrusted input inlined into run: is shell execution; pass it through an env: var and reference the shell variable.
  2. pull_request_target pwn request — write token + secrets plus a checkout of PR code runs the attacker as you; don't build or run untrusted PR code under it.
  3. Unpinned action — a moveable tag can be re-pointed; pin third-party actions to a full commit SHA and review updates.
Figure 7 · The three-line security syllabus — injection, pwn request, supply chain — each with the fix that neutralizes it.

They are: script injection — untrusted ${ github.event.* } interpolated into a run: block is shell execution; pull_request_target — it runs with a write token and secrets in the base repo’s context, so checking out and running the PR’s code hands your privileges to the author (the “pwn request”); and the unpinned action — a moveable tag can be re-pointed to new code. The fixes are cheap and worth making reflex:

# UNSAFE — a PR titled  $(rm -rf ~)  becomes a shell command on your runner:
#   - run: echo "Title is ${{ github.event.pull_request.title }}"

# SAFE — pass untrusted input through an env var; the shell treats it as data:
      - env:
          TITLE: ${{ github.event.pull_request.title }}
        run: echo "Title is $TITLE"

# And pin third-party actions to a full commit SHA — a tag can be re-pointed:
      - uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11  # v4.1.1
Not hypothetical. In March 2025 the widely-used tj-actions/changed-files action was compromised (CVE-2025-30066): attackers re-pointed its version tags to code that dumped runner secrets into build logs across 23,000+ repositories. Repos that had pinned it to a full commit SHA were unaffected — which is the whole argument for pinning.
Part 3Make it fast — then see the whole thing

“It’s slow, and the bill is climbing” — matrix, concurrency, cache, cost

Three levers turn one job into fleet-scale CI without new machinery. A matrix fans a job across combinations (Python versions, OSes); set fail-fast: false to see every result instead of cancelling siblings on the first red. Concurrency cancels a run when newer commits land on the same PR (and, for deploys, does the opposite — one at a time, never cancel a live one). A cache skips repeated dependency installs.

  test:
    strategy:
      fail-fast: false                        # let ALL legs finish; don't cancel siblings
      matrix:
        python: ["3.10", "3.11", "3.12"]      # fan this job across versions
    runs-on: ubuntu-latest

# top level — cancel a run when new commits land on the same PR:
concurrency:
  group: ci-${{ github.ref }}
  cancel-in-progress: true                    # (for deploys, do the OPPOSITE: never cancel)

Then the bill. On public repos, standard runners are free. On private repos you pay per minute, rounded up, with an OS multiplier that dwarfs every other knob — roughly Linux ×1, Windows ×2, macOS ×10. So the biggest cost lever is which runner you pick; after that, caching and cancellation. Larger runners and artifact/cache storage are billed on top.

Step back — the whole thing, in one picture

You’ve now met every piece by hitting the wall that needs it. Here they are as one map: an event fires a workflow, which wires it to isolated jobs on runners, each running steps and actions; data crosses between jobs only through the artifact/cache/output corridor; a scoped token authorizes them; and an Environment gate can hold a deploy.

The GitHub Actions object model — one event, isolated jobs, data crosses only through the corridor Event push · pull_request workflow_dispatch schedule · workflow_call 1 Workflow · .github/workflows/*.yml Job · build / test 2 Runner · ubuntu-latest / self-hosted (ephemeral VM) Step · run: Action · uses: — composite / JS / Docker Data corridor · artifacts · cache · outputs — the only link between jobs 3 Job · deploy (needs: build) 4 Runner · fresh VM (nothing carried over) Step · run: Action · uses: Env gate reviewers 5 GITHUB_TOKEN · secrets · OIDC — authorizes both jobs (scope least-privilege) 6 Your step (run:) Reusable action (uses:) Data corridor Token / OIDC Env gate (approval)
  1. An event (push, pull_request, dispatch, schedule, workflow_call) starts a run.
  2. The workflow wires that event to jobs; build/test runs first, on its own runner, holding steps and actions.
  3. Nothing on a runner's disk survives — data reaches the next job only through the artifacts / cache / outputs corridor.
  4. Deploy runs on a fresh runner and pulls what it needs from the corridor.
  5. An Environment gate can hold the deploy for required reviewers before it runs.
  6. GITHUB_TOKEN / secrets / OIDC authorize both jobs — scope them least-privilege.
Figure 8 · The object model — one event fans out to isolated jobs on their own runners; a scoped token authorizes them, an Environment gate can hold the deploy, and data crosses only through the corridor.

And four things stay true no matter how large your pipelines get, worth committing to memory:

  • Jobs are isolated. Only artifacts, cache, and outputs cross between them.
  • Fork PRs get no secrets — by design; don’t defeat it with pull_request_target.
  • Actions triggered by GITHUB_TOKEN don’t recursively trigger workflows — a push made with the default token won’t kick off another run (loop prevention; surprises people expecting a follow-on).
  • A tag is a moving target; a SHA is not. Pin third-party actions by commit.

The rest of the map — a reference shelf

The build above uses the pieces you reach for daily. The rest, kept compact so nothing’s lost:

Events beyond push / pull_request

EventFires onNote
workflow_dispatcha manual click / API calltakes typed inputs; the “Run workflow” button
schedulecron (UTC)best-effort timing; runs the default branch’s file
workflow_callanother workflowmakes this workflow reusable
release / issues / …repo activityeach has activity types and filters (branches, paths, tags)

Where data and config live

MechanismUse it for
$GITHUB_OUTPUTa step output (promote to a job output to cross jobs)
$GITHUB_ENV / $GITHUB_PATHenv var / PATH for later steps in the same job
$GITHUB_STEP_SUMMARYmarkdown that renders in the Checks tab & PR
contexts & functionsgithub, needs, matrix… with hashFiles, fromJSON, contains, status checks

Runners

GitHub-hosted = a clean ephemeral VM per run (Ubuntu / Windows / macOS, plus larger runners with more vCPU/RAM/GPU). Self-hosted = your machines, chosen by label, autoscalable (ARC on Kubernetes) — but never attach a self-hosted runner to a public repo: a forked PR can run arbitrary code on it. Keep public-repo CI on ephemeral hosted runners.

References

  1. GitHub — Understanding GitHub Actions and Workflow syntax for GitHub Actions (events, jobs, steps, needs, permissions). docs.github.com/en/actions
  2. GitHub — Events that trigger workflows (push, pull_request, pull_request_target, workflow_dispatch, schedule, workflow_call). docs.github.com/en/actions/using-workflows/events-that-trigger-workflows
  3. GitHub — Contexts and Expressions (github/needs/matrix contexts, hashFiles, fromJSON, status functions). docs.github.com/en/actions/learn-github-actions/contexts
  4. GitHub — Using GitHub-hosted runners and About self-hosted runners (ephemeral VMs, labels, larger runners, public-repo warning). docs.github.com/en/actions/using-github-hosted-runners
  5. GitHub — Storing and sharing data from a workflow (artifacts) and Caching dependencies (actions/cache, keys and restore-keys). docs.github.com/en/actions/using-workflows/storing-workflow-data-as-artifacts
  6. GitHub — Reusing workflows and Creating a composite action (workflow_call, nesting/count limits, runs.using: composite). docs.github.com/en/actions/using-workflows/reusing-workflows
  7. GitHub — Automatic token authentication and Assigning permissions to jobs (GITHUB_TOKEN, per-scope permissions). docs.github.com/en/actions/security-guides/automatic-token-authentication
  8. GitHub — About security hardening with OpenID Connect (id-token: write, cloud trust policies, temporary credentials). docs.github.com/en/actions/deployment/security-hardening-your-deployments/about-security-hardening-with-openid-connect
  9. GitHub — Using environments for deployment (environment secrets, required reviewers, wait timers, branch policies). docs.github.com/en/actions/deployment/targeting-different-environments/using-environments-for-deployment
  10. GitHub — Security hardening for GitHub Actions (script injection, pull_request_target risks, pinning third-party actions to a full SHA). docs.github.com/en/actions/security-guides/security-hardening-for-github-actions
  11. GitHub Security Lab — Keeping your GitHub Actions and workflows secure: Preventing pwn requests. securitylab.github.com/research/github-actions-preventing-pwn-requests/
  12. GitHub — About billing for GitHub Actions (per-minute billing, OS multipliers, larger-runner rates, storage). docs.github.com/en/billing/managing-billing-for-github-actions
  13. GitHub — Actions limits (concurrent-job caps by plan, 256-job matrix, 6-hour job / 35-day run, 500 runs / 10 s queue, API rate limits). docs.github.com/en/actions/reference/limits
  14. GitHub — Actions runner pricing and About billing for GitHub Actions (per-minute rates; OS multipliers Linux ×1 / Windows ×2 / macOS ×10; larger-runner rates; storage). docs.github.com/en/billing
  15. GitHub Changelog — Deprecation of Node 20 on GitHub Actions runners (runners default to Node 24 from 2026-06-16; Node 20 EOL April 2026). github.blog/changelog
  16. CISA / GitHub Advisory Database — Supply-chain compromise of tj-actions/changed-files (CVE-2025-30066) and reviewdog/action-setup (CVE-2025-30154), March 2025. cisa.gov · github.com/advisories