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 as actions/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 push event triggers the workflow file ci.yml. The workflow contains two jobs, each on its own runner. Job test runs the steps checkout, setup-python and pytest. Job deploy needs job test, so it starts only after test succeeds, and runs the steps checkout and deploy. Event push workflow: .github/workflows/ci.yml Job: test runner: ubuntu-latest uses: checkout uses: setup-python run: pytest needs Job: deploy runner: ubuntu-latest uses: checkout run: ./deploy.sh
Event → workflow → jobs (each on its own runner) → steps.

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
  • name is what the Actions tab shows. on lists the events: pushes to main and every pull request.
  • permissions: contents: read limits the automatic token to reading the repository (see GITHUB_TOKEN). Write it in every workflow.
  • jobs.test is one job with the ID test. The check it reports, which you can choose as a required status check, is named after the job's name if it has one, otherwise its ID (here test); a matrix adds the values, for example test (3.12). runs-on picks the runner.
  • A fresh runner has no copy of your code: the first step, actions/checkout, clones it. actions/setup-python installs the requested Python version; with passes inputs to an action. The last two steps are plain shell commands.
  • @v7 selects a version of the action: the current major versions of checkout and setup-python as 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.