Manage GitHub Actions for the enterprise

Running Actions for many teams at once. Sharing reusable building blocks and controlling who may use what, setting policy at enterprise and organisation level, running and monitoring fleets of GitHub-hosted and self-hosted runners, and managing secrets and variables at the right scope — by hand and through the API.

Skill
4
Exam weight
20–25%
Skills measured
January 2026
Sections
3

Distribute and govern actions and workflows

Define and manage reusable components and templates

At scale, standard automation is a small set of owned, versioned building blocks: reusable workflows for whole pipelines, composite or JavaScript actions for shared steps, and workflow templates that point new repositories at them.

a typical layout
octo-org/platform/                      # owned by the platform team
├── .github/
│   ├── CODEOWNERS                       # /.github/workflows/ @octo-org/platform
│   └── workflows/
│       ├── node-ci.yml                  # reusable: on: workflow_call
│       ├── container-build.yml          # reusable
│       └── deploy.yml                   # reusable, uses environments
└── actions/
    ├── setup-toolchain/action.yml       # composite
    └── notify/action.yml                # JavaScript

octo-org/.github/                        # organisation-wide defaults
└── workflow-templates/
    ├── node-service.yml                 # calls platform/node-ci.yml@v3
    └── node-service.properties.json
  • Keep reusable workflows and actions in a repository owned by a platform team, protected by CODEOWNERS and rulesets, and released with tags like any other code.
  • Callers pin a major (@v3) or a SHA; the platform team ships fixes by moving the tag and breaking changes as a new major.
  • Templates in .github/workflow-templates/ give every new repository a starting workflow that calls the central one.
  • To make a workflow mandatory, use a repository ruleset at organisation level with Require workflows to pass before merging — the successor of "required workflows". It runs the chosen workflow on pull requests to targeted repositories and blocks merging until it passes.

Control access to actions and workflows

A private or internal repository's actions and reusable workflows are invisible to other repositories until that repository opts in under Settings → Actions → General → Access:

Access levelWho can use its actions and reusable workflows
Not accessible (default)Only workflows in the same repository
Accessible from repositories owned by the userThe owner's other private repositories (personal accounts)
Accessible from repositories in the organisationPrivate and internal repositories in the same organisation
Accessible from repositories in the enterprisePrivate and internal repositories in every organisation of the enterprise (internal repositories)

That grants the right to run the code, not to read the repository. The organisation's allowed-actions policy still applies on top — an action can be shared and still be blocked.

Configure organisational use policies

flowchart TB
  subgraph ENT["Enterprise"]
    direction TB
    E1["Actions policies<br/>allowed actions, SHA pinning, retention,<br/>fork PRs, runner groups"]
    subgraph ORG["Organization"]
      direction TB
      O1["Same policies,<br/>only as strict or stricter"]
      subgraph REPO["Repository"]
        direction TB
        R1["Settings → Actions → General<br/>within what the org allows"]
      end
    end
  end
  E1 ~~~ O1
  O1 ~~~ R1
Actions policy cascades down. Each level can only keep or tighten what the level above allows: a repository cannot re-enable an action its organisation blocks, or raise a retention period above the organisation's.
PolicyOptions
Which repositories may use ActionsAll, selected repositories, or none (disables Actions)
Allowed actions and reusable workflowsAllow all · allow only the enterprise's or organisation's own · allow those plus selected others: actions created by GitHub, Marketplace actions by verified creators, and specified patterns (owner/*, owner/repo@ref, and !pattern to block)
Require actions to be pinned to a full-length commit SHAA checkbox under the allowed-actions choice (since August 2025): any tag- or branch-pinned action fails the workflow
Fork pull request workflowsRequire approval for first-time contributors new to GitHub, for all first-time contributors, or for all outside collaborators; for private repositories, whether fork PRs run at all, get a write token or receive secrets
Workflow permissionsDefault GITHUB_TOKEN: read repository contents and packages (the default for new organisations) or read and write; and whether Actions may create and approve pull requests
Artifact and log retentionMaximum days; repositories may only shorten it
RunnersWhich repositories may use self-hosted runners; runner groups and their access; cache storage limits
gh api
# Organisation: allow Actions everywhere, but only selected actions, pinned to SHAs
gh api -X PUT orgs/octo-org/actions/permissions \
  -f enabled_repositories=all -f allowed_actions=selected -F sha_pinning_required=true

gh api -X PUT orgs/octo-org/actions/permissions/selected-actions --input - <<'EOF'
{
  "github_owned_allowed": true,
  "verified_allowed": true,
  "patterns_allowed": [
    "docker/*",
    "azure/login@*",
    "octo-org/*",
    "!some-vendor/risky-action@*"
  ]
}
EOF

# Default GITHUB_TOKEN permissions for new workflows: read-only, and no PR approvals
gh api -X PUT orgs/octo-org/actions/permissions/workflow \
  -f default_workflow_permissions=read -F can_approve_pull_request_reviews=false

The blocklist is evaluated last

In patterns_allowed, a pattern prefixed with ! blocks, and blocking wins over every allow rule — use it to stop one compromised action even while "actions by verified creators" is allowed. Patterns match public actions and reusable workflows; your own organisation's are covered by the "allow the organisation's own" part of the policy.

Manage runners at scale

GitHub-hosted runners

A GitHub-hosted runner is a fresh virtual machine for every job, deleted afterwards, with a large set of preinstalled tools. Current labels, checked on 5 November 2026:

LabelImageNotes
ubuntu-latestUbuntu 24.04 → Ubuntu 26.04Gradual move between 19 October and 19 November 2026; pin ubuntu-24.04 to stay
ubuntu-26.04, ubuntu-24.04, ubuntu-22.04Ubuntu, x64-arm suffix for Arm64 (ubuntu-24.04-arm)
ubuntu-slimA lightweight, container-based Linux runnerFor quick, low-resource automation jobs
windows-latest, windows-2025Windows Server 2025windows-latest moved from 2022 in September 2025; windows-2022 still available
windows-11-armWindows 11, Arm64
macos-latest, macos-26macOS 26, Arm64macos-15 available; macos-14 deprecated; Intel via -intel and -large labels

Larger runners (GitHub Team and Enterprise Cloud) add more CPU and memory, GPUs and Arm64 machines, static public IP ranges, Azure private networking (the runner joins your Azure virtual network) and custom images. They are created by organisation or enterprise owners, always belong to a runner group, and autoscale up to a configured maximum.

Monitoring: organisation Insights → Actions usage metrics and Actions performance metrics show minutes, job counts, queue times and failure rates by workflow, repository and runner type; billing shows spend; the workflow_job webhook reports every job queued, started and completed — the signal autoscalers use.

Self-hosted runners

A self-hosted runner is your machine — physical, virtual, container or cloud — running GitHub's open-source runner application. It opens an outbound HTTPS long poll to GitHub; no inbound port is needed. It can be registered to a repository, an organisation or an enterprise.

Register a self-hosted runner
  1. Choose the level and open the runner settings

    Repository, organisation or enterprise Settings → Actions → Runners → New self-hosted runner. You need admin rights at that level. Pick the operating system and architecture.

  2. Download and extract the runner package

    The page shows the download commands and a checksum to verify. The runner keeps itself up to date after that.

  3. Configure it with a registration token

    The page's token is valid for one hour (or create one with POST /orgs/{org}/actions/runners/registration-token). Add a name, labels and a group:

    shell
    # Linux or macOS, in the folder where the runner package was extracted
    ./config.sh --url https://github.com/octo-org \
      --token "$REGISTRATION_TOKEN" \
      --name build-linux-01 \
      --runnergroup production \
      --labels linux-x64,docker,gpu \
      --ephemeral                           # take one job, then de-register
    
    sudo ./svc.sh install && sudo ./svc.sh start   # run as a service
  4. Run it as a service

    ./run.sh runs interactively; svc.sh (Linux, macOS) or the Windows service option keeps it running after reboots.

  5. Target it from a workflow

    Every runner gets the default labels self-hosted, its OS and its architecture, plus yours:

    .github/workflows/build.yml excerpt
    jobs:
      build:
        runs-on: [self-hosted, linux, x64, gpu]   # every label must match one runner
    
      deploy:
        runs-on:
          group: production                       # a runner group…
          labels: [linux-x64]                     # …narrowed by labels
    
      scale:
        runs-on: arc-runner-set                   # an Actions Runner Controller scale set
  • Ephemeral runners (--ephemeral, or a just-in-time config from generate-jitconfig) take a single job and de-register — clean state every time, and the pattern autoscalers use.
  • Actions Runner Controller (ARC) runs autoscaling ephemeral runners on Kubernetes as runner scale sets, installed with Helm; workflows target the scale set's name in runs-on.
  • A runner that has not connected for 14 days is removed automatically (1 day for ephemeral runners).
  • Runners update themselves; one that has not updated within 30 days of a new release stops receiving jobs. Pin images to a current version if you disable auto-update.
  • A self-hosted job can run for up to 5 days and wait up to 24 hours in the queue.

Never attach self-hosted runners to public repositories

Anyone who opens a pull request from a fork could run code on the machine, and a persistent runner keeps whatever that code leaves behind. Use GitHub-hosted runners for public repositories, and ephemeral, isolated runners for anything that runs untrusted code. Runner groups do not allow public repositories unless you tick that option.

IP allow lists and networking

  • An organisation or enterprise IP allow list blocks access from any address not on it — including standard GitHub-hosted runners, whose addresses are dynamic. A workflow then fails to clone or call the API.
  • Fixes: run those jobs on larger runners with static IP ranges and add the ranges to the list, or on self-hosted runners with known egress addresses, or use Azure private networking.
  • GitHub publishes hosted-runner ranges in GET /meta (the actions key), but they are numerous and change — not a practical allow-list source.
  • Self-hosted runners need outbound access to GitHub's hosts, among them github.com, api.github.com, *.actions.githubusercontent.com, codeload.github.com, results-receiver.actions.githubusercontent.com and the blob storage used for artifacts, caches and logs.
  • Behind a proxy, set https_proxy, http_proxy and no_proxy in the runner's environment or its .env file. ./run.sh --check tests connectivity.

Runner groups

A runner group is a set of runners with an access policy. Organisation groups choose which repositories can use them; enterprise groups choose which organisations. Every runner is in exactly one group — the Default group if you do not choose.

  • Repository access: all repositories, or selected ones; public repositories only if explicitly allowed.
  • Workflow access: optionally restrict the group to selected workflows, named by path and ref (octo-org/platform/.github/workflows/deploy.yml@refs/heads/main) — the way to keep production runners for the deploy pipeline only.
  • Target a group with runs-on: group:, optionally narrowed with labels:.
  • Moving a runner between groups is a setting; it keeps its labels.
flowchart TD
  Q["Job stuck: waiting for a runner"] --> A{"Any online runner with<br/>every requested label?"}
  A -->|"no"| A1["Fix labels, or bring a runner online"]
  A -->|"yes"| B{"Is its group available<br/>to this repository?"}
  B -->|"no"| B1["Grant the repo or org access;<br/>allow public repos if needed"]
  B -->|"yes"| C{"Is the group limited to<br/>selected workflows?"}
  C -->|"yes, not this one"| C1["Add the workflow file and ref"]
  C -->|"no"| D["Check the runner's _diag logs,<br/>version and network access"]
Troubleshooting a job that never starts. On the runner itself, the _diag folder holds Runner_ and Worker_ logs; the runner list shows each runner as Idle, Active or Offline.

Preinstalled software and the toolcache

Every GitHub-hosted image is built from the actions/runner-images repository, which publishes a README per image listing every tool and version, and release notes for each weekly image update. The Set up job step of every job prints the exact image and version, with links:

Set up job
Runner Image
Image: ubuntu-24.04
Version: 20261027.1
Included Software: https://github.com/actions/runner-images/blob/ubuntu24/20261027.1/images/ubuntu/Ubuntu2404-Readme.md
Image Release: https://github.com/actions/runner-images/releases/tag/ubuntu24%2F20261027.1
Runner Image Provisioner
Hosted Compute Agent
GITHUB_TOKEN Permissions
Contents: read
Metadata: read
Secret source: Actions
Prepare workflow directory
Prepare all required actions
Getting action download info
Download action repository 'actions/checkout@v7' (SHA:3d3c42e5aac5ba805825da76410c181273ba90b1)
Complete job name: build
  • The toolcache ($RUNNER_TOOL_CACHE) holds several versions of runtimes such as Node.js, Python, Go, Java and Ruby. setup-* actions use a cached version first and download others.
  • $ImageOS and $ImageVersion identify the image inside a step.
  • Upcoming changes — a -latest label moving, a tool removed — are announced in the runner-images repository and the GitHub Changelog weeks ahead.

Installing more at runtime, from cheapest to most work:

.github/workflows/tools.yml excerpt
steps:
  - uses: actions/setup-python@v7            # a version from the toolcache, or downloaded
    with:
      python-version: '3.14'
      cache: pip
  - uses: actions/setup-java@v6
    with:
      distribution: temurin
      java-version: '25'
  - name: System packages
    run: sudo apt-get update && sudo apt-get install -y --no-install-recommends graphviz
  - name: Which versions are on this image?
    run: |
      echo "Image: $ImageOS $ImageVersion"
      ls "$RUNNER_TOOL_CACHE"
ApproachWhen
setup-* actionsLanguage runtimes at a chosen version, with built-in dependency caching
Package managers — apt-get (with passwordless sudo), brew, choco, wingetA few system tools
actions/cacheDownloads that are slow to repeat
container: on the jobA whole prepared toolchain in an image you control
Custom images — larger-runner custom images, or your own self-hosted imageHeavy or licensed toolchains used by many jobs

Manage encrypted secrets and variables

Define and scope secrets and variables

ScopeSecretsVariablesWho sees them
OrganisationUp to 1,000Up to 1,000Repositories chosen by the access policy: all, private and internal, or selected
RepositoryUp to 100Up to 500All workflows in the repository
EnvironmentUp to 100Up to 100Only jobs that reference the environment, after its protection rules pass
  • When names collide, the most specific scope wins: environment, then repository, then organisation.
  • Each secret or variable is limited to 48 KB; organisation and repository variables together are limited to 256 KB per run. A workflow sees at most 100 organisation secrets (the first 100 alphabetically).
  • Names: letters, digits and underscores; not starting with a digit or GITHUB_; stored upper-case and matched case-insensitively.
  • Secrets are encrypted client-side (libsodium sealed boxes), write-only in the UI, and masked in logs. Variables are plain text, readable in the UI and in logs — for configuration, never credentials.
  • Dependabot and Codespaces have their own secret stores; Actions secrets are not passed to Dependabot-triggered runs, which get Dependabot secrets instead.
  • Secrets are not passed to workflows triggered by pull requests from forks (apart from a read-only GITHUB_TOKEN).

Access and use them in workflows and actions

.github/workflows/deploy.yml excerpt
jobs:
  deploy:
    runs-on: ubuntu-latest
    environment: production                   # environment secrets and variables
    steps:
      - uses: actions/checkout@v7
      - name: Deploy
        env:
          DEPLOY_KEY: ${{ secrets.DEPLOY_KEY }}  # environment beats repo beats org
          API_URL: ${{ vars.API_URL }}
          REGION: ${{ vars.REGION || 'eu-west' }} # variables can have fallbacks
        run: ./deploy.sh --url "$API_URL" --region "$REGION"

  call-shared:
    uses: octo-org/platform/.github/workflows/deploy.yml@v3
    secrets: inherit                          # every secret this workflow can see
  • secrets.NAME and vars.NAME are expression contexts; map them into env or with. An unset secret evaluates to an empty string, not an error.
  • Actions never read secrets on their own: JavaScript and Docker actions get them as inputs or environment variables; composite actions only through inputs.
  • Reusable workflows receive secrets only when the caller passes them explicitly or with secrets: inherit.
  • GITHUB_TOKEN is available as secrets.GITHUB_TOKEN and github.token without being created.

Manage them programmatically

gh
# GitHub CLI: encrypts the value with the right public key for you
gh secret set NPM_TOKEN --body "$NPM_TOKEN"                       # repository
gh secret set DEPLOY_KEY --env production < deploy_key.pem        # environment
gh secret set SONAR_TOKEN --org octo-org --visibility selected --repos web,api
gh variable set API_URL --env staging --body https://staging.api.example.com
gh secret list --org octo-org
REST endpoints
# 1 · Fetch the public key the value must be encrypted with
GET /repos/{owner}/{repo}/actions/secrets/public-key
#   → { "key_id": "568250167242549743", "key": "base64 public key" }

# 2 · Encrypt the value with a libsodium sealed box, then create or update it
PUT /repos/{owner}/{repo}/actions/secrets/{secret_name}
#   { "encrypted_value": "base64 ciphertext", "key_id": "568250167242549743" }

# Organisation secrets also take a visibility: all, private or selected
PUT /orgs/{org}/actions/secrets/{secret_name}
#   { "encrypted_value": "…", "key_id": "…", "visibility": "selected",
#     "selected_repository_ids": [1296269, 1296280] }

# Environment secrets live under the environment
PUT /repos/{owner}/{repo}/environments/{environment_name}/secrets/{secret_name}

# Variables are plain text: no encryption step
POST  /repos/{owner}/{repo}/actions/variables              { "name": "API_URL", "value": "…" }
PATCH /repos/{owner}/{repo}/actions/variables/{name}       { "value": "…" }

Secrets go in encrypted; nothing comes back out

The REST API never returns a secret's value — GET returns only its name, dates and (for organisation secrets) visibility. To create or update one you must first fetch the scope's public key and encrypt the value with it; the request carries encrypted_value and key_id. Managing secrets needs admin access to the repository (or the fine-grained Secrets write permission); organisation secrets need organisation admin rights.

Security of the token itself — its permissions, lifetime and alternatives — is in Skill 5.