GitHub Actions runners can be organized into logical groups, and these groups can be granted specific access controls, allowing you to manage who can run jobs on which runners.

Let’s see this in action. Imagine you have a team that works on a critical internal application, and you want to ensure their CI/CD jobs only run on a dedicated set of self-hosted runners that are air-gapped from the public internet.

Here’s a simplified workflow file:

name: Internal App CI

on: [push]

jobs:
  build:
    runs-on:
      group: internal-app-runners
    steps:
      - name: Checkout code
        uses: actions/checkout@v4
      - name: Build application
        run: echo "Building internal app..."

And here’s how you’d configure the runner and the group in GitHub:

  1. Set up a self-hosted runner:

    • Go to your organization’s Settings -> Actions -> Runners.
    • Click New runner.
    • Follow the instructions to download and configure the runner software on your machine.
    • During configuration, you’ll be prompted for a runner group. You can create a new one here or select an existing one. Let’s create internal-app-runners.
    • You’ll also assign labels. For this example, let’s assign the label self-hosted.
    • The runner will register with GitHub and appear in your list of runners.
  2. Configure access controls for the runner group:

    • Navigate to the repository where you want to use these runners.
    • Go to Settings -> Actions -> Runners.
    • Under Runner groups, you’ll see internal-app-runners.
    • Click on internal-app-runners.
    • Under Repository access, you can choose All repositories (not recommended for sensitive runners), None, or Select repositories.
    • Select Select repositories and add your internal application repository.
    • You can also choose Grant access to all repositories or Grant access to private repositories on the organization level for runner groups, which is often more practical.

This setup ensures that only jobs targeting the internal-app-runners group can execute on those specific self-hosted machines. The runs-on: group: internal-app-runners directive in the workflow file tells GitHub Actions to look for an available runner within that designated group.

The core problem this solves is granular control over where your CI/CD workloads execute. Without runner groups, you’d either have all your repositories using a shared pool of runners (potentially exposing sensitive code to less secure environments or incurring unnecessary costs), or you’d be manually managing runner configurations per repository. Runner groups abstract this, allowing you to define a policy for a set of runners and then grant access to that policy to specific repositories or the entire organization.

Internally, GitHub Actions maps these runner groups to specific runner registration tokens and API endpoints. When a workflow job is scheduled, the Actions runner controller identifies which repositories are authorized to use a given runner group. If a job specifies runs-on: group: <group-name>, the controller then looks for an idle runner within that group that also meets any other specified labels (like self-hosted in our example). The runner itself, when it registers, is associated with a specific group ID.

The exact levers you control are:

  • Runner Group Name: A descriptive name for your collection of runners (e.g., internal-app-runners, macos-build-farm, windows-testing-pool).
  • Runner Assignment: Which individual runners belong to which group. This is done during runner configuration or by moving existing runners between groups in the GitHub UI.
  • Repository Access: Which repositories (or all repositories within an organization) are allowed to use jobs that target a specific runner group. This is managed at the organization level for runner groups.

When you assign a runner to a group, you’re essentially tagging that runner with the group’s identity. When a workflow job requests a runner from a specific group, GitHub Actions filters its available runners to only consider those tagged with the requested group ID and that also satisfy any additional labels specified in the runs-on clause. This two-stage filtering (group first, then labels) provides a robust access control mechanism.

A common pitfall is forgetting that repository-level access controls for runner groups override organization-level ones. If a runner group is configured to grant access to "All repositories" at the organization level, but then you explicitly deny access to a specific repository for that same group at the repository settings, the denial takes precedence for that repository.

The next concept you’ll likely encounter is managing runner labels to further refine job placement within a group.

Want structured learning?

Take the full Github-actions course →