Skip to content

Latest commit

 

History

1 Commit

Folders and files

NameName
Last commit message
Last commit date
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

🐙 GitHub Actions Runner Group Terraform Module

Manages a single organization-scoped GitHub Actions runner group — visibility scoping, selected-repository access, public-repo gating, and workflow restriction, all secure-by-default. Built for integrations/github v6.x.

Terraform GitHub provider module type resources


🧩 Overview

This module wraps the single github_actions_runner_group resource — the org-level boundary that decides which repositories (and optionally which workflows) are allowed to dispatch jobs to a set of self-hosted runners.

  • 🏷️ Names an organization runner group (name, unique within the org).
  • 🔐 Scopes visibility to selected repos (default, least-privilege) or all repos.
  • 🎯 Allows-lists repositories by numeric repo_id when visibility = "selected".
  • 🚫 Gates public repositories off by default (allows_public_repositories = false).
  • 🧱 Restricts workflows to an explicit allow-list (restricted_to_workflows + selected_workflows).
  • 📤 Emits the runner group id plus runtime attributes (default, inherited, API URLs, etag) for downstream wiring.

💡 Why it matters: Self-hosted runners execute workflow code on infrastructure you own. A runner group is the only org-level control that keeps an untrusted or public repo from scheduling jobs on those machines — getting its defaults right is a supply-chain security decision, not a convenience.


❤️ Support this project

If these Terraform modules have been helpful to you or your organization, I'd appreciate your support in any of the following ways:

Whether it's a star, a professional connection, or a coffee, every gesture helps keep these modules actively maintained and continually improving. Thank you for being part of the community!


🗺️ Where this fits in the family

This module is the access-scoping boundary between the keystone repository module and any billed runner compute — it consumes repository IDs and hands its own id to the modules that need to place runners inside the boundary.

flowchart LR
 repo["terraform-github-repository<br/>(keystone)"]
 runnergroup["terraform-github-actions-runner-group<br/>(THIS MODULE)"]
 hostedrunner["terraform-github-actions-hosted-runner"]
 actorg["terraform-github-actions-organization"]

 repo -->|"repo_id"| runnergroup
 runnergroup -->|"id (runner_group_id)"| hostedrunner
 runnergroup -->|"id"| actorg

 style runnergroup fill:#8957E5,color:#fff
 style repo fill:#24292F,color:#fff
Loading

This module consumes selected_repository_ids (numeric repo_ids from terraform-github-repository); it emits id — consumed by terraform-github-actions-hosted-runner (runner_group_id) and referenced by terraform-github-actions-organization / runner tooling — see the Typical wiring section.


🧬 What this module builds

A single, flat organization-scoped runner group — every argument is a scalar or list attribute, so there are no nested or dynamic blocks to render.

flowchart TD
 this["github_actions_runner_group.this<br/>(keystone)<br/>flat org-scoped access boundary (visibility, repo/workflow allow-lists)"]

 style this fill:#8957E5,color:#fff
Loading

📁 Module Structure

terraform-github-actions-runner-group/
├── providers.tf # terraform >= 1.12.0; integrations/github ~> 6.0
├── variables.tf # name, visibility, selected_repository_ids, flags, workflows
├── main.tf # github_actions_runner_group.this (flat, no dynamic blocks)
├── outputs.tf # id + name/visibility/default/inherited/URLs/etag
├── SCOPE.md # scopes, prerequisites, emits, gotchas
└── README.md # this file

⚙️ Quick Start

module "ci_runner_group" {
  source = "git::https://github.com/microsoftexpert/terraform-github-actions-runner-group?ref=v1.0.0"

  name = "linux-self-hosted"

  # Least-privilege default: only the listed repos may use these runners.
  visibility              = "selected"
  selected_repository_ids = [module.repository.repo_id]
}

ℹ️ The target organization (owner / GITHUB_OWNER) and authentication are configured on the caller's provider block, never as module variables.


🔌 Typical wiring

Every output in outputs.tf gets a row. The runner group id is the primary cross-module reference; selected_repository_ids consumes repo IDs from the keystone repository module.

Output Type Typically consumed by
id string (numeric runner group ID) terraform-github-actions-organization / runner registration tooling; terraform import target
name string human reference; runner registration scripts
visibility string (all | selected) governance / drift reporting
default bool governance checks (is this the org default group?)
inherited bool governance checks (enterprise-inherited group?)
allows_public_repositories bool security posture reporting
restricted_to_workflows bool security posture reporting
selected_workflows list(string) audit of allow-listed workflows
selected_repository_ids list(number) audit of repos with runner access
runners_url string runner inventory tooling (GitHub API)
selected_repositories_url string repo-access auditing (GitHub API)
etag string change-detection / caching

⚠️ selected_repository_ids takes the numeric repo_id (database ID), not the repository name. Wire it from module.repository.repo_id, never module.repository.id.


🧠 Architecture Notes

  • The id is a numeric runner group ID — not a name or node id. github_actions_runner_group.this.id is the runner group's integer database ID assigned by GitHub. This is also the value you pass to terraform import github_actions_runner_group.this <id>.
  • No node_id / repo_id / full_name / slug / html_url. Those attributes belong to repository- and team-shaped resources. A runner group is an org-scoped Actions config object and the provider exposes none of them, so the module deliberately does not emit them — over-emitting a nonexistent attribute is a plan error, not a courtesy. Use id for references and runners_url / selected_repositories_url for API-level lookups.
  • name is the natural identity but is mutable. Renaming the group updates it in place (no ForceNew); GitHub keys the resource on the numeric ID, so a rename does not replace the group or its runner registrations.
  • "private" visibility is rejected at plan time. GitHub documents a private value, but the GitHub API does not currently support it and it fails at apply. The module's validation {} block rejects it early — use "selected" to scope access narrowly.
  • Conditional arguments are passed unconditionally and ignored by the API when out of scope. selected_repository_ids is honored only when visibility = "selected"; selected_workflows only when restricted_to_workflows = true. Both default to [], so main.tf passes them directly with no null guards and no dynamic blocks — the resource is a flat, total projection of the typed inputs.
  • restricted_to_workflows = true with an empty selected_workflows allows nothing. The flag is an allow-list switch; turning it on without populating the list blocks every workflow on the group.
  • Org-scope, not enterprise-scope. This module manages github_actions_runner_group (organization). Enterprise-level groups are a different resource (github_enterprise_actions_runner_group) and are out of scope. The inherited output reports whether this org group was pushed down from the enterprise level.
  • No secrets handled here. Unlike Actions secret modules, a runner group has no plaintext_value / encrypted_value surface — nothing in this resource is sensitive. (Runner registration tokens are issued out-of-band by the runner installer, not by Terraform.)
  • Rulesets vs branch protection — not applicable. This resource governs runner access, not branches, so the rulesets-over-branch-protection preference does not bite here. The relevant control plane is visibility + selected_repository_ids.

📚 Example Library

1 · Minimal (secure default)
module "rg_minimal" {
  source = "git::https://github.com/microsoftexpert/terraform-github-actions-runner-group?ref=v1.0.0"
  name   = "default-self-hosted"
  # visibility defaults to "selected" with an empty allow-list — no repo can
  # use the group until you add IDs. Locked-down by default.
}
2 · Selected repositories (explicit allow-list)
module "rg_selected" {
  source = "git::https://github.com/microsoftexpert/terraform-github-actions-runner-group?ref=v1.0.0"

  name                    = "linux-builders"
  visibility              = "selected"
  selected_repository_ids = [101234567, 109876543] # numeric repo_id values
}
3 · Visible to all repositories in the org
module "rg_all" {
  source = "git::https://github.com/microsoftexpert/terraform-github-actions-runner-group?ref=v1.0.0"

  name       = "org-wide-runners"
  visibility = "all"
  # selected_repository_ids is ignored when visibility = "all".
}
4 · Allow public repositories (explicit opt-in)
module "rg_public" {
  source = "git::https://github.com/microsoftexpert/terraform-github-actions-runner-group?ref=v1.0.0"

  name                       = "oss-runners"
  visibility                 = "selected"
  selected_repository_ids    = [102002002]
  allows_public_repositories = true # ⚠️ accepts untrusted external PR workflows
}
5 · Restrict to specific workflows
module "rg_workflow_locked" {
  source = "git::https://github.com/microsoftexpert/terraform-github-actions-runner-group?ref=v1.0.0"

  name                    = "deploy-only"
  visibility              = "selected"
  selected_repository_ids = [105550001]

  restricted_to_workflows = true
  selected_workflows = [
    "octo-org/infra/.github/workflows/deploy.yml@refs/heads/main",
  ]
}
6 · Multiple allow-listed workflows
module "rg_multi_workflow" {
  source = "git::https://github.com/microsoftexpert/terraform-github-actions-runner-group?ref=v1.0.0"

  name                    = "release-pipeline"
  visibility              = "selected"
  selected_repository_ids = [105550001, 105550002]

  restricted_to_workflows = true
  selected_workflows = [
    "octo-org/app/.github/workflows/build.yml@refs/heads/main",
    "octo-org/app/.github/workflows/release.yml@refs/tags/v*",
  ]
}
7 · Fully hardened variant
module "rg_hardened" {
  source = "git::https://github.com/microsoftexpert/terraform-github-actions-runner-group?ref=v1.0.0"

  name                       = "prod-deploy-runners"
  visibility                 = "selected"  # narrow exposure
  selected_repository_ids    = [105550001] # one repo only
  allows_public_repositories = false       # no untrusted public PRs
  restricted_to_workflows    = true        # allow-list workflows
  selected_workflows = [
    "octo-org/prod-infra/.github/workflows/apply.yml@refs/heads/main",
  ]
}
8 · Cross-module wiring with the keystone repository
module "repository" {
  source = "git::https://github.com/microsoftexpert/terraform-github-repository?ref=v1.0.0"
  name   = "payments-service"
}

module "rg_wired" {
  source = "git::https://github.com/microsoftexpert/terraform-github-actions-runner-group?ref=v1.0.0"

  name                    = "payments-runners"
  visibility              = "selected"
  selected_repository_ids = [module.repository.repo_id] # numeric repo_id, not id
}
9 · Wiring several repositories from a list of modules
module "rg_team_group" {
  source = "git::https://github.com/microsoftexpert/terraform-github-actions-runner-group?ref=v1.0.0"

  name       = "platform-team-runners"
  visibility = "selected"
  selected_repository_ids = [
    module.repo_api.repo_id,
    module.repo_web.repo_id,
    module.repo_jobs.repo_id,
  ]
}
10 · `for_each` at scale from a `map(object)`
locals {
  runner_groups = {
    linux = {
      name                    = "linux-self-hosted"
      visibility              = "selected"
      selected_repository_ids = [105550001, 105550002]
    }
    windows = {
      name                    = "windows-self-hosted"
      visibility              = "selected"
      selected_repository_ids = [105550003]
    }
    org_wide = {
      name                    = "org-wide"
      visibility              = "all"
      selected_repository_ids = []
    }
  }
}

module "runner_groups" {
  source   = "git::https://github.com/microsoftexpert/terraform-github-actions-runner-group?ref=v1.0.0"
  for_each = local.runner_groups

  name                    = each.value.name
  visibility              = each.value.visibility
  selected_repository_ids = each.value.selected_repository_ids
}
11 · `for_each` with full per-group config
locals {
  groups = {
    deploy = {
      name                       = "deploy-runners"
      visibility                 = "selected"
      selected_repository_ids    = [105550001]
      allows_public_repositories = false
      restricted_to_workflows    = true
      selected_workflows         = ["octo-org/infra/.github/workflows/deploy.yml@refs/heads/main"]
    }
    oss = {
      name                       = "oss-runners"
      visibility                 = "selected"
      selected_repository_ids    = [105550009]
      allows_public_repositories = true
      restricted_to_workflows    = false
      selected_workflows         = []
    }
  }
}

module "runner_groups_full" {
  source   = "git::https://github.com/microsoftexpert/terraform-github-actions-runner-group?ref=v1.0.0"
  for_each = local.groups

  name                       = each.value.name
  visibility                 = each.value.visibility
  selected_repository_ids    = each.value.selected_repository_ids
  allows_public_repositories = each.value.allows_public_repositories
  restricted_to_workflows    = each.value.restricted_to_workflows
  selected_workflows         = each.value.selected_workflows
}
12 · Wiring runner IDs from a `for_each` repository module
module "repos" {
  source   = "git::https://github.com/microsoftexpert/terraform-github-repository?ref=v1.0.0"
  for_each = toset(["api", "web", "jobs"])
  name     = "service-${each.key}"
}

module "rg_from_repos" {
  source                  = "git::https://github.com/microsoftexpert/terraform-github-actions-runner-group?ref=v1.0.0"
  name                    = "all-services"
  visibility              = "selected"
  selected_repository_ids = [for r in module.repos : r.repo_id]
}

🔒 Default posture reminder: with no overrides, a group is visibility = "selected" with an empty allow-list, allows_public_repositories = false, and restricted_to_workflows = false. No repository can use it until you add IDs — fail-closed by design.


📦 Inputs (high-level)

Identity

  • name (string, required) — unique runner group name within the org.

Access scoping

  • visibility (string, default "selected") — "all" or "selected". "private" is rejected (unsupported by the API).
  • selected_repository_ids (list(number), default []) — numeric repo_ids; honored only when visibility = "selected".
  • allows_public_repositories (bool, default false) — opt-in for public repos.

Workflow restriction

  • restricted_to_workflows (bool, default false) — switch the workflow allow-list on.
  • selected_workflows (list(string), default []) — fully-qualified workflow refs; honored only when restricted_to_workflows = true.

🧾 Outputs

Output Description
id Runner group numeric ID (primary cross-module reference; terraform import target).
name Name of the runner group.
visibility Effective visibility (all or selected).
default Whether this is the org's default runner group.
inherited Whether the group is inherited from the enterprise level.
allows_public_repositories Whether public repos may be added.
restricted_to_workflows Whether the group is restricted to selected_workflows only.
selected_workflows Allow-listed workflows (when restricted_to_workflows = true).
selected_repository_ids Numeric repo IDs with access (when visibility = "selected").
runners_url GitHub API URL for the group's runners.
selected_repositories_url GitHub API URL for the group's selected repositories.
etag An etag representing the runner group object.

ℹ️ No output is sensitive — this resource holds no secret material.


🧱 Design Principles

  • Fail-closed by default. visibility = "selected" with an empty allow-list means no repo can reach the runners until explicitly granted.
  • Least-privilege access surface. Prefer "selected" + a tight selected_repository_ids list over "all"; opt into allows_public_repositories only when an OSS workflow genuinely needs it.
  • No untrusted public code on owned infra by default. allows_public_repositories = false keeps external-PR workflows off self-hosted runners unless deliberately enabled.
  • Workflow allow-listing for high-trust groups. restricted_to_workflows lets a production-deploy group run only its sanctioned workflows.
  • Auth & org are provider concerns. No owner / token / app_auth / base_url variables — the caller's provider block owns them.
  • Total projection, no hidden logic. Every input maps 1:1 to a resource argument; no count, no dynamic, no conditional nulls.

🔑 Required token scopes / GitHub App permissions

Scope / Permission Required for Notes
Classic PAT — admin:org Create / read / update / delete org runner groups Broad org-admin scope; works for all runner-group operations.
Classic PAT — manage_runners:org Managing self-hosted runners & runner groups Narrower, dedicated scope — prefer this over admin:org when available (least-privilege).
Fine-grained PAT / GitHub App — Organization → Self-hosted runners (Read & write) All runner-group CRUD The specific fine-grained permission for this resource.
Fine-grained PAT / GitHub App — Organization → Administration (Read) Resolving org context Read-level org administration access supports lookups in some provider paths.
Repository repo_id source selected_repository_ids wiring The IDs come from the repository module; no extra repo scope is needed on this identity to reference numeric IDs.

⚠️ This is an organization-admin operation. The provider identity must hold admin:org (or manage_runners:org) on a classic PAT, or the Self-hosted runners (read & write) organization permission on a fine-grained PAT / GitHub App. A bare repo or repository-scoped token cannot create or modify org runner groups and will fail with a 403. SSO-protected orgs additionally require the PAT to be SSO-authorized for the org.


🧰 GitHub Prerequisites

  • Plan / edition: Organization runner groups (beyond the single built-in Default group) require GitHub Team or GitHub Enterprise Cloud (or Enterprise Server). On Free orgs only the default group exists, so creating a group with this module will fail.
  • Actions enabled: GitHub Actions must be enabled for the organization (Org → Settings → Actions). Self-hosted runners must be permitted by org policy.
  • Enterprise inheritance: A group with inherited = true is managed at the enterprise level (github_enterprise_actions_runner_group), not the org — do not try to manage an inherited group with this module.
  • "private" visibility: Unsupported by the GitHub API; rejected at plan time. Use "selected".
  • SSO authorization: For orgs with SAML/SSO enforced, the PAT must be authorized for the org or all calls return 403.
  • API rate limits (bulk for_each): Each runner group is a separate set of REST calls. Large for_each deployments can hit GitHub's secondary rate limits (abuse-detection throttling on rapid bursts), surfacing as 403 You have exceeded a secondary rate limit. Apply in smaller batches or with -parallelism lowered if you manage many groups at once.

🚀 Runbook

terraform init -backend=false
terraform validate
terraform fmt -check
terraform plan
terraform apply
terraform output

⚠️ Always pin the module source to a tag — ?ref=v1.0.0 — never a branch. Branch refs drift silently between applies.

To adopt an existing group into state:

terraform import github_actions_runner_group.this <runner_group_id>

🔍 Troubleshooting

Symptom Cause Resolution
403 on create/update Token lacks org-admin scope Use a PAT with admin:org / manage_runners:org, or a fine-grained token with Self-hosted runners (read & write).
403 secondary rate limit Too many groups created in one burst Reduce -parallelism, split the for_each into batches, or retry after a short backoff.
Apply error mentioning private visibility visibility = "private" requested Not supported by the API; the module rejects it at plan time — use "selected".
Group created but no repo can use it visibility = "selected" with an empty selected_repository_ids Add numeric repo_ids (from module.repository.repo_id).
Workflows blocked unexpectedly restricted_to_workflows = true with empty/incorrect selected_workflows Populate selected_workflows with fully-qualified refs, or set restricted_to_workflows = false.
selected_repository_ids shows constant drift Repo names passed instead of numeric IDs Use repo_id (database ID), not id / repo name.
Cannot modify an inherited group Group is enterprise-managed (inherited = true) Manage it via github_enterprise_actions_runner_group, not this module.
404 / creation fails on a Free org Plan doesn't support custom runner groups Upgrade to Team or Enterprise Cloud.

🔗 Related Docs

  • The keystone terraform-github-repository module — emits repo_id consumed here.
  • The terraform-github-actions-organization module — org-level Actions policy that complements runner groups.
  • integrations/github provider — Actions Runner Group resource reference.
  • GitHub Docs — Managing access to self-hosted runners using groups.

Releases

Packages

Contributors

Languages