GitHub Actions
Automating work on GitHub: workflows, triggers, jobs, steps and runners, using and pinning actions, matrices, secrets, the GITHUB_TOKEN, caching and artifacts, and reusable workflows.
Concepts
GitHub Actions is GitHub's built-in automation and CI/CD (continuous integration and continuous delivery) platform: it runs workflows defined in a repository on virtual machines or containers when something happens in that repository, on a schedule, or on request. It is used for testing and deploying, but also for labeling issues, publishing releases or rendering documentation. The building blocks:
-
A workflow is an automated process
defined in a
YAML file in
.github/workflows/; it declares which events trigger it and which jobs it runs. A repository can have many. - An event is the thing that happened (a push, a new pull request, a schedule); see triggers below.
- A job is a set of steps that run in order on the same runner. Jobs in a workflow run in parallel by default, unless one needs another.
-
A step is a single task inside a job:
either a shell command (
run) or an action (uses). Steps of one job share the runner's file system, so a later step sees what an earlier one created. - A runner is the machine that executes a job: a fresh virtual machine hosted by GitHub, or one of your own. Each job gets its own runner.
-
An action is a reusable, packaged
unit of automation that a step calls with
uses, such asactions/checkout, which clones the repository onto the runner. Actions come from GitHub, from other publishers on the GitHub Marketplace, or from your own repository.
A first workflow
The netops team keeps its Python tools in a repository and wants the
tests to run on every push to main and on every pull
request. The complete workflow, saved as
.github/workflows/ci.yml:
name: CI
on:
push:
branches: [main]
pull_request:
permissions:
contents: read
jobs:
test:
runs-on: ubuntu-latest
steps:
- name: Check out the repository
uses: actions/checkout@v7
- name: Set up Python
uses: actions/setup-python@v7
with:
python-version: "3.12"
- name: Install dependencies
run: pip install -r requirements.txt
- name: Run the tests
run: pytest
-
nameis what the Actions tab shows.onlists the events: pushes tomainand every pull request. -
permissions: contents: readlimits the automatic token to reading the repository (see GITHUB_TOKEN). Write it in every workflow. -
jobs.testis one job with the IDtest. The check it reports, which you can choose as a required status check, is named after the job'snameif it has one, otherwise its ID (heretest); a matrix adds the values, for exampletest (3.12).runs-onpicks the runner. -
A fresh runner has no copy of your code: the first step,
actions/checkout, clones it.actions/setup-pythoninstalls the requested Python version;withpasses inputs to an action. The last two steps are plain shell commands. -
@v7selects a version of the action: the current major versions ofcheckoutandsetup-pythonas of 2026. Check each action's releases when you copy an older example.
Commit the file and push: the run appears in the repository's Actions tab, and on pull requests as a check. If a command exits with a non-zero status, the step fails, the job fails, and the check turns red.
Triggers
A workflow trigger is an
event, listed under the on key, that starts a workflow
run, such as a push, a pull request, a schedule or a manual start;
filters narrow it down to certain branches, paths or activity types.
The ones you will use most:
| Event | Runs when | Useful filters and options |
|---|---|---|
push |
Commits or tags are pushed |
branches, tags, paths
|
pull_request |
A pull request is opened, updated or reopened |
branches (the target branch),
paths, types
|
schedule |
At times given in cron syntax, in UTC (Coordinated Universal Time) | One or more cron lines |
workflow_dispatch |
Someone starts it manually (Actions tab,
gh workflow run, the API (application
programming interface))
|
inputs shown as a form |
workflow_call |
Another workflow calls it (a reusable workflow) |
inputs, secrets,
outputs
|
release |
A GitHub release changes | types, e.g. published |
merge_group |
A pull request is checked in the merge queue | — |
on:
push:
branches: [main, "release/**"]
paths:
- "src/**"
- "requirements.txt"
pull_request:
branches: [main]
schedule:
- cron: "30 5 * * 1-5" # 05:30 UTC, Monday to Friday
workflow_dispatch:
inputs:
environment:
description: "Where to deploy"
type: choice
options: [staging, production]
default: staging
dry-run:
description: "Only show what would change"
type: boolean
default: true
release:
types: [published]
Inside the workflow, a manual input is read as
${{ inputs.environment }}. The
${{ … }} syntax is an expression: GitHub
evaluates it before the step runs, using contexts such as
github (the event and repository), inputs,
secrets, vars, matrix,
steps and needs.
Jobs and steps
Jobs and dependencies
Jobs run in parallel unless needs says otherwise. A job
that needs another starts only after it succeeds, and can read its
outputs. This workflow lints and tests in parallel, then builds only
if both passed:
name: Build
on:
push:
branches: [main]
permissions:
contents: read
jobs:
lint:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- run: pipx run yamllint inventory/
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- uses: actions/setup-python@v7
with:
python-version: "3.12"
- run: pip install -r requirements.txt
- run: pytest
build:
needs: [lint, test]
runs-on: ubuntu-latest
outputs:
version: ${{ steps.version.outputs.version }}
steps:
- uses: actions/checkout@v7
with:
fetch-depth: 0 # full history, so git describe finds tags
- id: version
run: echo "version=$(git describe --tags)" >> "$GITHUB_OUTPUT"
- run: tar -czf "netbox-sync-${{ steps.version.outputs.version }}.tar.gz" src/
report:
needs: build
runs-on: ubuntu-latest
steps:
- run: echo "Built version ${{ needs.build.outputs.version }}"
Inside a step
| Key | Purpose |
|---|---|
uses |
Run an action, owner/repo@ref, or a local one,
./.github/actions/name
|
run |
Run shell commands (Bash on Linux and macOS runners by
default); | for several lines
|
with |
Inputs for the action in uses |
env |
Environment variables for this step (also possible per job or workflow) |
if |
A condition; the step is skipped when it is false, e.g.
github.ref == 'refs/heads/main' or
failure()
|
id |
A name to refer to the step's outputs:
steps.<id>.outputs.<name>
|
A step communicates with the runner through files whose paths are in
environment variables. Appending name=value to
$GITHUB_OUTPUT sets a step output, as in the
version step above. Appending Markdown to
$GITHUB_STEP_SUMMARY adds it to the run's summary page,
a convenient place for a test report or a list of changed devices:
- name: Summary
if: always() # also after a failed step
run: |
echo "### Config check" >> "$GITHUB_STEP_SUMMARY"
echo "- Devices checked: 42" >> "$GITHUB_STEP_SUMMARY"
echo "- Python: $(python --version)" >> "$GITHUB_STEP_SUMMARY"
Matrix strategy
A matrix strategy runs the same job several times with different values for one or more variables, one job per combination, for example every supported Python version on every operating system. Combinations run in parallel:
jobs:
test:
strategy:
fail-fast: false # let the other combinations finish
matrix:
os: [ubuntu-latest, windows-latest]
python-version: ["3.11", "3.12", "3.13"]
runs-on: ${{ matrix.os }}
steps:
- uses: actions/checkout@v7
- uses: actions/setup-python@v7
with:
python-version: ${{ matrix.python-version }}
- run: pip install -r requirements.txt
- run: pytest
Two operating systems times three versions gives six jobs. By
default (fail-fast: true) one failing combination
cancels the others still running; include and
exclude add or remove single combinations.
Runners
runs-on picks a runner by label. GitHub-hosted runners
are fresh virtual machines, created for one job and deleted
afterward, with many tools preinstalled. Labels as of October 2026:
| Label | Image | Other labels |
|---|---|---|
ubuntu-latest |
Ubuntu 24.04 |
ubuntu-26.04, ubuntu-22.04
(deprecated since September 2026, retired April 2027); Arm:
ubuntu-24.04-arm and others
|
windows-latest |
Windows Server 2025 |
windows-2022, windows-2025; Arm:
windows-11-arm
|
macos-latest |
macOS 26 (Apple silicon) |
macos-14, macos-15; Intel:
macos-15-intel
|
A standard Linux or Windows runner has 4 vCPUs (virtual CPUs) and 16
GB of memory for public repositories, and 2 vCPUs with 8 GB for
private ones. The -latest labels move to a newer image
every year or two; when a build must not change underneath you, use
a versioned label such as ubuntu-24.04.
Self-hosted runners
A self-hosted runner is a
machine you manage yourself, with GitHub's runner application
installed, that picks up jobs for a repository, organization or
enterprise. Teams use them to reach internal networks (a lab of
routers behind a firewall, say), for special hardware, or to control
cost. You target them with labels:
runs-on: [self-hosted, linux, lab]. Do not give a
self-hosted runner a label such as ubuntu-latest
that belongs to GitHub-hosted runners.
Billing basics
As of 2026: standard GitHub-hosted runners are free for public repositories, and self-hosted runners carry no platform fee. Private repositories get included minutes per month (2,000 on Free, 3,000 on Pro and Team, 50,000 on Enterprise Cloud) and then pay per minute; after a price cut on 1 January 2026, a standard Linux runner costs USD 0.006 per minute, Windows USD 0.010 and macOS USD 0.062. A per-minute charge for self-hosted runners announced in December 2025 was postponed. Prices change, so check GitHub's billing documentation, and remember that a matrix of six jobs uses six times the minutes.
Secrets, tokens and permissions
Secrets and variables
Secrets (passwords, API keys) are stored encrypted under Settings →
Secrets and variables → Actions, at repository, environment or
organization level, and read with
${{ secrets.NAME }}. GitHub masks their values in logs.
Configuration that is not secret (a server name, a region) goes into
variables, read with ${{ vars.NAME }}. Pass a
secret to the one step that needs it, through env:
- name: Update NetBox
env:
NETBOX_URL: ${{ vars.NETBOX_URL }}
NETBOX_TOKEN: ${{ secrets.NETBOX_TOKEN }}
run: python sync.py
Workflows triggered by pull requests from forks do not receive your secrets. Never write a secret's value into a workflow file or echo it on purpose; masking catches the exact value, not a transformed one. See also Securing repositories.
The GITHUB_TOKEN
The GITHUB_TOKEN is a token that
GitHub creates automatically for each workflow job, which lets the
job call the GitHub API and use Git on its own repository and stops
working when the job ends; its rights are set by the
permissions key. Read it as
${{ secrets.GITHUB_TOKEN }} (or
${{ github.token }}); many actions use it by default.
Since 2 February 2023, new enterprises, organizations and
repositories default to a read-only token (read access to contents
and packages); older ones may still have the permissive read/write
default, and a new repository in an older organization inherits the
organization's setting. Do not depend on the default: write
permissions in every workflow, read-only at the top,
and raise it only in the job that needs more. A job-level block
replaces the workflow-level one for that job, and any permission you
do not list is set to none.
permissions:
contents: read # default for every job in this workflow
jobs:
label:
runs-on: ubuntu-latest
permissions:
contents: read
pull-requests: write # only this job may label pull requests
steps:
- run: gh pr edit "$PR" --add-label needs-review
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
GH_REPO: ${{ github.repository }}
PR: ${{ github.event.pull_request.number }}
OIDC: no stored cloud keys
To deploy to a cloud, the classic approach is to store a cloud
access key as a secret: long-lived, powerful, and painful to rotate.
OpenID Connect
(OIDC) is an identity standard built on OAuth 2.0; in GitHub Actions
a job uses it to obtain a short-lived, signed identity token that
describes the workflow (repository, branch, environment), which a
cloud provider that trusts GitHub exchanges for temporary
credentials. You configure the trust once in the cloud ("only this
repository, only the production environment, may take this role");
the workflow needs the
id-token: write permission and stores no cloud secret
at all:
jobs:
deploy:
runs-on: ubuntu-latest
environment: production
permissions:
id-token: write # may request an OIDC token
contents: read
steps:
- uses: actions/checkout@v7
# Next: the cloud provider's official login action, pinned to a
# commit SHA, exchanges the token for temporary credentials. For Amazon Web Services:
# uses: aws-actions/configure-aws-credentials@<commit SHA>
# with:
# role-to-assume: arn:aws:iam::111122223333:role/deploy-netbox-sync
# aws-region: eu-west-1
- run: ./deploy.sh
The cloud side of this (roles, trust policies) is covered in the Cloud topic under identity and access management.
Caching and artifacts
Every job starts on an empty machine, so by default it downloads all
dependencies again. A cache saves files between
runs under a key; when the key matches, the files are restored. The
key usually includes a hash of the lock file, so the cache is
rebuilt exactly when the dependencies change.
setup-python can do this itself (cache: pip); actions/cache (version 6 as of 2026) works for
anything:
- uses: actions/setup-python@v7
with:
python-version: "3.12"
cache: pip # built-in pip cache
- uses: actions/cache@v6 # the general-purpose way
with:
path: ~/.cache/pre-commit
key: ${{ runner.os }}-pre-commit-${{ hashFiles('.pre-commit-config.yaml') }}
An artifact is a file a job keeps after it ends: a test report, a build result, rendered configurations. It can be downloaded from the run page or by a later job, which is how one job hands its build artifact to the deploy job. Logs and artifacts are kept for 90 days by default (configurable per repository).
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- run: python render.py --out build/configs
- uses: actions/upload-artifact@v7
with:
name: configs
path: build/configs/
retention-days: 14
deploy:
needs: build
runs-on: ubuntu-latest
steps:
- uses: actions/download-artifact@v8
with:
name: configs
path: configs/
- run: ls configs/
Use a cache for things you could download again (dependencies); use an artifact for things the run produced. Versions 1 to 3 of the artifact actions stopped working in 2024 and January 2025; old examples that use them fail.
Reusable workflows and composite actions
Once three repositories have the same test job, you want to keep it in one place. GitHub offers two mechanisms, plus starter workflows (now called workflow templates in GitHub's docs), which are only templates: copied into your repository once and independent afterward.
Reusable workflows
A reusable workflow is a
complete workflow with the workflow_call trigger, which
other workflows call as a job with uses, passing inputs
and secrets; it brings its own jobs, runners and steps. The shared
workflow, in a repository example-corp/workflows:
# example-corp/workflows: .github/workflows/python-tests.yml
name: Python tests
on:
workflow_call:
inputs:
python-version:
type: string
default: "3.12"
permissions:
contents: read
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- uses: actions/setup-python@v7
with:
python-version: ${{ inputs.python-version }}
- run: pip install -r requirements.txt
- run: pytest
And a repository that uses it, as a whole job:
name: CI
on: [push, pull_request]
permissions:
contents: read
jobs:
tests:
uses: example-corp/workflows/.github/workflows/python-tests.yml@v1
with:
python-version: "3.13"
Composite actions
A composite action is an
action defined in an action.yml file whose
runs section lists ordinary steps; it bundles those
steps so that a job can call them as a single step, on the caller's
runner. Note the shell key, which
run steps in a composite action require:
# .github/actions/setup-netops/action.yml
name: Set up netops tools
description: Install Python and the team's Python tools
inputs:
python-version:
description: Python version to install
default: "3.12"
runs:
using: composite
steps:
- uses: actions/setup-python@v7
with:
python-version: ${{ inputs.python-version }}
cache: pip
- run: pip install -r requirements.txt
shell: bash
steps:
- uses: actions/checkout@v7
- uses: ./.github/actions/setup-netops # a local composite action
- run: pytest
| Reusable workflow | Composite action | |
|---|---|---|
| Reuses | Whole jobs, with their own runs-on |
A group of steps |
| Called from | A job: jobs.<id>.uses |
A step: steps[*].uses |
| Runs on | Its own runners | The caller's runner, inside the caller's job |
| Secrets | Passed explicitly (or secrets: inherit) |
Passed as inputs or environment variables |
| Nesting (GitHub.com) | Up to 10 levels | Up to 10 composite actions in one workflow |
Keeping workflows secure
A workflow runs with your secrets and a token that can change your repository, and its actions are code written by others. The same supply chain risks described in supply chain attacks apply here.
Pin actions to a full commit SHA
A tag such as @v2 is a movable pointer: whoever
controls the action's repository can move it to different code
tomorrow, and your next run executes that code with your secrets.
GitHub's documentation calls pinning to a full-length commit SHA
(Secure Hash Algorithm) hash "the only way to use an action as an
immutable release". Keep the version as a comment so people (and
Dependabot, which updates both) know what it is:
steps:
# Illustrative SHA: copy the real one from the action's release page
- uses: example-org/yaml-lint-action@3f1c2a9e8b7d6c5f4e3a2b1c0d9e8f7a6b5c4d3e # v2.3.1
Since August 2025, administrators can enforce SHA pinning by policy
at enterprise, organization or repository level, and block
individual actions. Many teams pin every third-party action and
accept major-version tags only for GitHub's own
actions/*; pinning those too is stricter still.
Script injection
An expression in a run script is replaced by its value
before the shell sees the script. If the value comes from
someone outside your team (an issue title, a pull request title, a
branch name, a commit message), they control part of your script.
This example imitates what happens, with a harmless command in the
"title":
# The title of a new issue, typed by anyone on the internet
title='Broken link $(echo INJECTED: this command ran on the runner)'
# Unsafe: like ${{ github.event.issue.title }}, the text is pasted into the script
cat > unsafe.sh <<EOF
echo "New issue: $title"
EOF
bash unsafe.sh
# Safe: the script is fixed; the title arrives as an environment variable
cat > safe.sh <<'EOF'
echo "New issue: $TITLE"
EOF
TITLE="$title" bash safe.sh
New issue: Broken link INJECTED: this command ran on the runner New issue: Broken link $(echo INJECTED: this command ran on the runner)
In the first run, the $(…) inside the title became part
of the script and was executed; in a real workflow it could read the
token or the secrets. In the second, the shell treated it as data.
The fix in a workflow is the same: move the expression into
env and refer to the variable, in double quotes.
# Unsafe: the title becomes part of the script
- run: |
echo "New issue: ${{ github.event.issue.title }}"
# Safe: the title is passed as data
- env:
TITLE: ${{ github.event.issue.title }}
run: |
echo "New issue: $TITLE"
(The | is needed here for a YAML reason: a plain value
may not contain a colon followed by a space, as in
New issue: .)
Code scanning with CodeQL (see Securing repositories) also checks workflow files and flags this pattern.
pull_request_target
For pull requests from forks, the ordinary
pull_request event runs with a read-only token and
without your secrets: safe for untrusted code. The
pull_request_target event instead runs in the context
of the base repository, with access to secrets and a
possibly writable token, so that it can, for example, label or
comment on pull requests from forks. If such a workflow checks out
and builds the fork's code, a stranger's code runs with your secrets
(a so-called "pwn request"); it also shares the cache of the main
branch, which can be poisoned. GitHub's guidance: avoid
pull_request_target and workflow_run
for anything that handles untrusted code.
Since version 7 (June 2026), actions/checkout
refuses by default to check out a fork's pull request code in
pull_request_target and
workflow_run workflows; the change was also backported
to versions 2 to 6 in July 2026. The opt-out input
allow-unsafe-pr-checkout
is named as a warning: if you think you need it, redesign the
workflow instead.