diff --git a/GNUmakefile b/GNUmakefile
index c23c4b8b55..04824add4a 100644
--- a/GNUmakefile
+++ b/GNUmakefile
@@ -1,4 +1,4 @@
-SWEEP?=repositories,teams
+SWEEP?=all
PKG_NAME=github
TESTACC?=./$(PKG_NAME)/...
@@ -81,7 +81,7 @@ testacc:
sweep:
@echo "WARNING: This will destroy infrastructure. Use only in development accounts."
- go test $(TESTACC) -v -sweep=$(SWEEP) $(SWEEPARGS)
+ go test ./github -v -sweep=$(SWEEP) $(SWEEPARGS)
generatedocs:
@cd tools; go generate ./...
diff --git a/RESOURCES.md b/RESOURCES.md
index dd00ac0a50..d9256bdee1 100644
--- a/RESOURCES.md
+++ b/RESOURCES.md
@@ -122,7 +122,7 @@ The overall status of each resource or data source is captured in this document
| --- | --- | --- | --- | --- | --- | --- | --- |
| `github_actions_environment_secret` | ❓ | ❓ | ❓ | ❓ | ❓ | ❓ | ❓ |
| `github_actions_environment_variable` | ❓ | ❓ | ❓ | ❓ | ❓ | ❓ | ❓ |
-| `github_actions_hosted_runner` | ❓ | ❓ | ❓ | ❓ | ❓ | ❓ | ❓ |
+| `github_actions_hosted_runner` | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
| `github_actions_organization_oidc_subject_claim_customization_template` | ❓ | ❓ | ❓ | ❓ | ❓ | ❓ | ❓ |
| `github_actions_organization_permissions` | ❓ | ❓ | ❓ | ❓ | ❓ | ❓ | ❓ |
| `github_actions_organization_secret` | ❓ | ❓ | ❓ | ❓ | ❓ | ❓ | ❓ |
diff --git a/docs/resources/actions_hosted_runner.md b/docs/resources/actions_hosted_runner.md
index 5f0c4d1ba2..6cc2692a56 100644
--- a/docs/resources/actions_hosted_runner.md
+++ b/docs/resources/actions_hosted_runner.md
@@ -1,48 +1,51 @@
---
page_title: "github_actions_hosted_runner (Resource) - GitHub"
+subcategory: ""
description: |-
- Creates and manages GitHub-hosted runners within a GitHub organization
+ This resource allows you to create and manage GitHub-hosted runners within your GitHub organization. You must have admin access to an organization to use this resource.
---
# github_actions_hosted_runner (Resource)
This resource allows you to create and manage GitHub-hosted runners within your GitHub organization. You must have admin access to an organization to use this resource.
-GitHub-hosted runners are fully managed virtual machines that run your GitHub Actions workflows. Unlike self-hosted runners, GitHub handles the infrastructure, maintenance, and scaling.
+## Notes
-## Example Usage
+- This resource is **organization-only** and cannot be used with individual accounts.
+- Image IDs for GitHub-owned images are numeric strings (e.g., "2306" for Ubuntu Latest 24.04), not names like "ubuntu-latest".
+- Deletion of hosted runners is asynchronous. The provider will poll for up to 10 minutes (configurable via timeouts) to confirm deletion.
+- Runner creation and updates may take several minutes as GitHub provisions the infrastructure.
-### Basic Usage
+## Getting Available Images and Sizes
-```terraform
-resource "github_actions_runner_group" "example" {
- name = "example-runner-group"
- visibility = "all"
-}
+To get a list of available images:
-resource "github_actions_hosted_runner" "example" {
- name = "example-hosted-runner"
+```bash
+curl -H "Authorization: Bearer YOUR_TOKEN" \
+ -H "Accept: application/vnd.github+json" \
+ https://api.github.com/orgs/YOUR_ORG/actions/hosted-runners/images/github-owned
+```
- image {
- id = "2306"
- source = "github"
- }
+To get available machine sizes:
- size = "4-core"
- runner_group_id = github_actions_runner_group.example.id
-}
+```bash
+curl -H "Authorization: Bearer YOUR_TOKEN" \
+ -H "Accept: application/vnd.github+json" \
+ https://api.github.com/orgs/YOUR_ORG/actions/hosted-runners/machine-sizes
```
-### Advanced Usage with Optional Parameters
+## Example Usage
+
+### Advanced Usage
```terraform
-resource "github_actions_runner_group" "advanced" {
- name = "advanced-runner-group"
- visibility = "selected"
+resource "github_actions_runner_group" "example" {
+ name = "example-runner-group"
+ visibility = "all"
}
-resource "github_actions_hosted_runner" "advanced" {
- name = "advanced-hosted-runner"
+resource "github_actions_hosted_runner" "example" {
+ name = "example-hosted-runner"
image {
id = "2306"
@@ -50,104 +53,79 @@ resource "github_actions_hosted_runner" "advanced" {
}
size = "8-core"
- runner_group_id = github_actions_runner_group.advanced.id
+ runner_group_id = github_actions_runner_group.example.id
maximum_runners = 10
public_ip_enabled = true
}
```
-## Argument Reference
+
+## Schema
-The following arguments are supported:
+### Required
-- `name` - (Required) Name of the hosted runner. Must be between 1 and 64 characters and may only contain alphanumeric characters, '.', '-', and '_'.
-- `image` - (Required) Image configuration for the hosted runner. Cannot be changed after creation. Block supports:
- - `id` - (Required) The image ID. For GitHub-owned images, use numeric IDs like "2306" for Ubuntu Latest 24.04. To get available images, use the GitHub API: `GET /orgs/{org}/actions/hosted-runners/images/github-owned`.
- - `source` - (Optional) The image source. Valid values are "github", "partner", or "custom". Defaults to "github".
-- `size` - (Required) Machine size for the hosted runner (e.g., "4-core", "8-core"). Can be updated to scale the runner. To list available sizes, use the GitHub API: `GET /orgs/{org}/actions/hosted-runners/machine-sizes`.
-- `runner_group_id` - (Required) The ID of the runner group to assign this runner to.
-- `maximum_runners` - (Optional) Maximum number of runners to scale up to. Runners will not auto-scale above this number. Use this setting to limit costs.
-- `public_ip_enabled` - (Optional) Whether to enable static public IP for the runner. Note there are account limits. To list limits, use the GitHub API: `GET /orgs/{org}/actions/hosted-runners/limits`. Defaults to false.
-- `image_version` - (Optional) The version of the runner image to deploy. This is only relevant for runners using custom images.
+- `image` (Block List, Min: 1, Max: 1) Image configuration for the hosted runner. Cannot be changed after creation. (see [below for nested schema](#nestedblock--image))
+- `name` (String) Name of the hosted runner. Must be between 1 and 64 characters and may only contain upper and lowercase letters a-z, numbers 0-9, '.', '-', and '_'.
+- `runner_group_id` (Number) The ID of the runner group to assign this runner to.
+- `size` (String) Machine size for the hosted runner (e.g., `4-core`, `8-core`). Can be updated to scale the runner. To list available sizes, use the GitHub API: `GET /orgs/{org}/actions/hosted-runners/machine-sizes`.
-## Timeouts
+### Optional
-The `timeouts` block allows you to specify timeouts for certain actions:
+- `image_gen` (Boolean) Whether this runner should be used to generate custom images. Cannot be changed after creation.
+- `image_version` (String) The version of the runner image to deploy. This is only relevant for runners using custom images.
+- `maximum_runners` (Number) Maximum number of runners to scale up to. Runners will not auto-scale above this number. Use this setting to limit costs.
+- `public_ip_enabled` (Boolean) Whether to enable static public IP for the runner. Note there are account limits. To list limits, use the GitHub API: `GET /orgs/{org}/actions/hosted-runners/limits`. Defaults to false.
+- `timeouts` (Block, Optional) (see [below for nested schema](#nestedblock--timeouts))
-- `delete` - (Defaults to 10 minutes) Used for waiting for the hosted runner deletion to complete.
+### Read-Only
-Example:
+- `id` (String) The hosted runner ID.
+- `last_active_on` (String) Timestamp when the runner was last active.
+- `machine_size_details` (List of Object) Detailed machine size specifications. (see [below for nested schema](#nestedatt--machine_size_details))
+- `platform` (String) Platform of the runner.
+- `public_ips` (List of Object) List of public IP ranges assigned to this runner. (see [below for nested schema](#nestedatt--public_ips))
+- `status` (String) Current status of the runner.
-```terraform
-resource "github_actions_hosted_runner" "example" {
- name = "example-hosted-runner"
+
+### Nested Schema for `image`
- image {
- id = "2306"
- source = "github"
- }
+Required:
- size = "4-core"
- runner_group_id = github_actions_runner_group.example.id
+- `id` (String) The image ID.
- timeouts {
- delete = "15m"
- }
-}
-```
+Optional:
-## Attributes Reference
+- `source` (String) The image source (github, partner, or custom).
-In addition to the arguments above, the following attributes are exported:
+Read-Only:
-- `id` - The ID of the hosted runner.
-- `status` - Current status of the runner (e.g., "Ready", "Provisioning").
-- `platform` - Platform of the runner (e.g., "linux-x64", "win-x64").
-- `image` - In addition to the arguments above, the image block exports:
- - `size_gb` - The size of the image in gigabytes.
-- `machine_size_details` - Detailed specifications of the machine size:
- - `id` - Machine size identifier.
- - `cpu_cores` - Number of CPU cores.
- - `memory_gb` - Amount of memory in gigabytes.
- - `storage_gb` - Amount of storage in gigabytes.
-- `public_ips` - List of public IP ranges assigned to this runner (only if `public_ip_enabled` is true):
- - `enabled` - Whether this IP range is enabled.
- - `prefix` - IP address prefix.
- - `length` - Subnet length.
-- `last_active_on` - Timestamp (RFC3339) when the runner was last active.
+- `size_gb` (Number) The size of the image in GB.
-## Import
-Hosted runners can be imported using the runner ID:
+
+### Nested Schema for `timeouts`
-```shell
-terraform import github_actions_hosted_runner.example 123456
-```
+Optional:
-## Notes
+- `delete` (String)
-- This resource is **organization-only** and cannot be used with individual accounts.
-- The `image` field cannot be changed after the runner is created. Changing it will force recreation of the runner.
-- The `size` field can be updated to scale the runner up or down as needed.
-- Image IDs for GitHub-owned images are numeric strings (e.g., "2306" for Ubuntu Latest 24.04), not names like "ubuntu-latest".
-- Deletion of hosted runners is asynchronous. The provider will poll for up to 10 minutes (configurable via timeouts) to confirm deletion.
-- Runner creation and updates may take several minutes as GitHub provisions the infrastructure.
-- Static public IPs are subject to account limits. Check your organization's limits before enabling.
-## Getting Available Images and Sizes
+
+### Nested Schema for `machine_size_details`
-To get a list of available images:
+Read-Only:
-```bash
-curl -H "Authorization: Bearer YOUR_TOKEN" \
- -H "Accept: application/vnd.github+json" \
- https://api.github.com/orgs/YOUR_ORG/actions/hosted-runners/images/github-owned
-```
+- `cpu_cores` (Number)
+- `id` (String)
+- `memory_gb` (Number)
+- `storage_gb` (Number)
-To get available machine sizes:
-```bash
-curl -H "Authorization: Bearer YOUR_TOKEN" \
- -H "Accept: application/vnd.github+json" \
- https://api.github.com/orgs/YOUR_ORG/actions/hosted-runners/machine-sizes
-```
+
+### Nested Schema for `public_ips`
+
+Read-Only:
+
+- `enabled` (Boolean)
+- `length` (Number)
+- `prefix` (String)
diff --git a/examples/resources/actions_hosted_runner/example_2.tf b/examples/resources/actions_hosted_runner/example_2.tf
deleted file mode 100644
index ee7e3f69f7..0000000000
--- a/examples/resources/actions_hosted_runner/example_2.tf
+++ /dev/null
@@ -1,18 +0,0 @@
-resource "github_actions_runner_group" "advanced" {
- name = "advanced-runner-group"
- visibility = "selected"
-}
-
-resource "github_actions_hosted_runner" "advanced" {
- name = "advanced-hosted-runner"
-
- image {
- id = "2306"
- source = "github"
- }
-
- size = "8-core"
- runner_group_id = github_actions_runner_group.advanced.id
- maximum_runners = 10
- public_ip_enabled = true
-}
diff --git a/examples/resources/actions_hosted_runner/example_3.tf b/examples/resources/actions_hosted_runner/example_3.tf
deleted file mode 100644
index 195b7aeb8c..0000000000
--- a/examples/resources/actions_hosted_runner/example_3.tf
+++ /dev/null
@@ -1,15 +0,0 @@
-resource "github_actions_hosted_runner" "example" {
- name = "example-hosted-runner"
-
- image {
- id = "2306"
- source = "github"
- }
-
- size = "4-core"
- runner_group_id = github_actions_runner_group.example.id
-
- timeouts {
- delete = "15m"
- }
-}
diff --git a/examples/resources/actions_hosted_runner/example_1.tf b/examples/resources/github_actions_hosted_runner/resource_advanced.tf
similarity index 64%
rename from examples/resources/actions_hosted_runner/example_1.tf
rename to examples/resources/github_actions_hosted_runner/resource_advanced.tf
index ef5ae400c3..f43c543ee1 100644
--- a/examples/resources/actions_hosted_runner/example_1.tf
+++ b/examples/resources/github_actions_hosted_runner/resource_advanced.tf
@@ -11,6 +11,8 @@ resource "github_actions_hosted_runner" "example" {
source = "github"
}
- size = "4-core"
- runner_group_id = github_actions_runner_group.example.id
+ size = "8-core"
+ runner_group_id = github_actions_runner_group.example.id
+ maximum_runners = 10
+ public_ip_enabled = true
}
diff --git a/github/acc_helpers_test.go b/github/acc_helpers_test.go
index ebd2daf3af..fe3ed5c781 100644
--- a/github/acc_helpers_test.go
+++ b/github/acc_helpers_test.go
@@ -161,6 +161,38 @@ func mustCreateTestRepository(t *testing.T) *github.Repository {
return repo
}
+func mustCreateTestOrganizationActionsRunnerGroup(t *testing.T) *github.RunnerGroup {
+ t.Helper()
+
+ randomID := acctest.RandString(testRandomIDLength)
+ name := fmt.Sprintf("%s%s", testResourcePrefix, randomID)
+
+ req := github.CreateRunnerGroupRequest{
+ Name: &name,
+ Visibility: new("all"),
+ }
+
+ runnerGroup, _, err := testAccConf.meta.v3client.Actions.CreateOrganizationRunnerGroup(t.Context(), testAccConf.meta.name, req)
+ if err != nil {
+ t.Fatalf("failed to create test organization runner group: %v", err)
+ }
+
+ t.Cleanup(func() {
+ ctx := context.WithoutCancel(t.Context())
+ if err := sweepRunnerGroupRunners(ctx, runnerGroup); err != nil {
+ t.Logf("failed to delete runner group runners for %s: %v", runnerGroup.GetName(), err)
+ }
+ if _, err := testAccConf.meta.v3client.Actions.DeleteOrganizationRunnerGroup(ctx, testAccConf.meta.name, runnerGroup.GetID()); err != nil {
+ if err, ok := errors.AsType[*github.ErrorResponse](err); ok && err.Response.StatusCode == 404 {
+ return
+ }
+ t.Logf("failed to delete test organization runner group %s: %v", name, err)
+ }
+ })
+
+ return runnerGroup
+}
+
func mustAddRepositoryCollaborator(t *testing.T, repo *github.Repository, username string) {
t.Helper()
diff --git a/github/acc_test.go b/github/acc_test.go
index 4c9f72a4be..28e9037fe1 100644
--- a/github/acc_test.go
+++ b/github/acc_test.go
@@ -2,13 +2,16 @@ package github
import (
"context"
+ "errors"
"fmt"
+ "net/http"
"net/url"
"os"
"slices"
"strconv"
"strings"
"testing"
+ "time"
"github.com/google/go-github/v88/github"
"github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema"
@@ -267,6 +270,11 @@ func configureSweepers() {
Name: "teams",
F: sweepTeams,
})
+
+ resource.AddTestSweepers("organization_runner_groups", &resource.Sweeper{
+ Name: "organization_runner_groups",
+ F: sweepOrganizationRunnerGroups,
+ })
}
func sweepTeams(_ string) error {
@@ -329,6 +337,76 @@ func sweepRepositories(_ string) error {
return nil
}
+func sweepOrganizationRunnerGroups(_ string) error {
+ if !slices.Contains(orgTestModes, testMode(os.Getenv("GH_TEST_AUTH_MODE"))) {
+ return nil
+ }
+
+ client := testAccConf.meta.v3client
+ owner := testAccConf.meta.name
+ ctx := context.Background()
+
+ fmt.Println("sweeping organization runner groups")
+ runnerGroups, _, err := client.Actions.ListOrganizationRunnerGroups(ctx, owner, &github.ListOrgRunnerGroupOptions{})
+ if err != nil {
+ return err
+ }
+
+ for _, rg := range runnerGroups.GetRunnerGroups() {
+ if name := rg.GetName(); strings.HasPrefix(name, testResourcePrefix) {
+ fmt.Printf("destroying organization runner group %s\n", name)
+
+ if err := sweepRunnerGroupRunners(ctx, rg); err != nil {
+ return fmt.Errorf("failed to sweep runner group runners for %s: %w", rg.GetName(), err)
+ }
+
+ if resp, err := client.Actions.DeleteOrganizationRunnerGroup(ctx, owner, rg.GetID()); err != nil {
+ if resp != nil && resp.StatusCode == http.StatusNotFound {
+ continue
+ }
+ return err
+ }
+ }
+ }
+
+ return nil
+}
+
+func sweepRunnerGroupRunners(ctx context.Context, rg *github.RunnerGroup) error {
+ client := testAccConf.meta.v3client
+ owner := testAccConf.meta.name
+
+ fmt.Printf("cleanup: removing organization hosted runners of runner group '%s'\n", rg.GetName())
+ runners, _, err := client.Actions.ListRunnerGroupHostedRunners(ctx, owner, rg.GetID(), &github.ListOptions{})
+ if err != nil {
+ return err
+ }
+
+ for _, r := range runners.GetRunners() {
+ _, resp, err := client.Actions.DeleteHostedRunner(ctx, owner, r.GetID())
+ if err != nil {
+ if resp != nil && resp.StatusCode == http.StatusNotFound {
+ continue
+ }
+ if _, ok := errors.AsType[*github.AcceptedError](err); ok {
+ if err := waitForRunnerDeletion(ctx, client, owner, r.GetID(), 5*time.Minute); err != nil {
+ return fmt.Errorf("failed to wait for runner deletion %s: %w", r.GetName(), err)
+ }
+ continue
+ }
+ return fmt.Errorf("failed to delete hosted runner %s: %w", r.GetName(), err)
+ }
+
+ if resp != nil && resp.StatusCode == http.StatusAccepted {
+ if err := waitForRunnerDeletion(ctx, client, owner, r.GetID(), 5*time.Minute); err != nil {
+ return fmt.Errorf("failed to wait for runner deletion %s: %w", r.GetName(), err)
+ }
+ }
+ }
+
+ return nil
+}
+
func skipUnauthenticated(t *testing.T) {
if testAccConf.authMode == anonymous {
t.Skip("Skipping as test mode not authenticated")
diff --git a/github/resource_github_actions_hosted_runner.go b/github/resource_github_actions_hosted_runner.go
index 027fb648a2..3657063d8f 100644
--- a/github/resource_github_actions_hosted_runner.go
+++ b/github/resource_github_actions_hosted_runner.go
@@ -4,13 +4,15 @@ import (
"context"
"errors"
"fmt"
- "log"
"net/http"
"regexp"
"strconv"
"time"
"github.com/google/go-github/v88/github"
+ "github.com/hashicorp/terraform-plugin-log/tflog"
+ "github.com/hashicorp/terraform-plugin-sdk/v2/diag"
+ "github.com/hashicorp/terraform-plugin-sdk/v2/helper/customdiff"
"github.com/hashicorp/terraform-plugin-sdk/v2/helper/retry"
"github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema"
"github.com/hashicorp/terraform-plugin-sdk/v2/helper/validation"
@@ -18,10 +20,10 @@ import (
func resourceGithubActionsHostedRunner() *schema.Resource {
return &schema.Resource{
- Create: resourceGithubActionsHostedRunnerCreate,
- Read: resourceGithubActionsHostedRunnerRead,
- Update: resourceGithubActionsHostedRunnerUpdate,
- Delete: resourceGithubActionsHostedRunnerDelete,
+ CreateContext: resourceGithubActionsHostedRunnerCreate,
+ ReadContext: resourceGithubActionsHostedRunnerRead,
+ UpdateContext: resourceGithubActionsHostedRunnerUpdate,
+ DeleteContext: resourceGithubActionsHostedRunnerDelete,
Importer: &schema.ResourceImporter{
StateContext: schema.ImportStatePassthroughContext,
},
@@ -30,6 +32,10 @@ func resourceGithubActionsHostedRunner() *schema.Resource {
Delete: schema.DefaultTimeout(10 * time.Minute),
},
+ Description: "This resource allows you to create and manage GitHub-hosted runners within your GitHub organization. You must have admin access to an organization to use this resource.",
+
+ CustomizeDiff: customdiff.All(resourceGithubActionsHostedRunnerValidation),
+
Schema: map[string]*schema.Schema{
"name": {
Type: schema.TypeString,
@@ -44,10 +50,11 @@ func resourceGithubActionsHostedRunner() *schema.Resource {
Description: "Name of the hosted runner. Must be between 1 and 64 characters and may only contain upper and lowercase letters a-z, numbers 0-9, '.', '-', and '_'.",
},
"image": {
- Type: schema.TypeList,
- Required: true,
- ForceNew: true,
- MaxItems: 1,
+ Type: schema.TypeList,
+ Required: true,
+ ForceNew: true,
+ MaxItems: 1,
+ Description: "Image configuration for the hosted runner. Cannot be changed after creation.",
Elem: &schema.Resource{
Schema: map[string]*schema.Schema{
"id": {
@@ -69,35 +76,34 @@ func resourceGithubActionsHostedRunner() *schema.Resource {
},
},
},
- Description: "Image configuration for the hosted runner. Cannot be changed after creation.",
},
"size": {
Type: schema.TypeString,
Required: true,
- Description: "Machine size (e.g., '4-core', '8-core'). Can be updated to scale the runner.",
+ Description: "Machine size for the hosted runner (e.g., `4-core`, `8-core`). Can be updated to scale the runner. To list available sizes, use the GitHub API: `GET /orgs/{org}/actions/hosted-runners/machine-sizes`.",
},
"runner_group_id": {
Type: schema.TypeInt,
Required: true,
- Description: "The runner group ID.",
+ Description: "The ID of the runner group to assign this runner to.",
},
"maximum_runners": {
Type: schema.TypeInt,
Optional: true,
Computed: true,
ValidateDiagFunc: validation.ToDiagFunc(validation.IntAtLeast(1)),
- Description: "Maximum number of runners to scale up to.",
+ Description: "Maximum number of runners to scale up to. Runners will not auto-scale above this number. Use this setting to limit costs.",
},
"public_ip_enabled": {
Type: schema.TypeBool,
Optional: true,
Default: false,
- Description: "Whether to enable static public IP.",
+ Description: "Whether to enable static public IP for the runner. Note there are account limits. To list limits, use the GitHub API: `GET /orgs/{org}/actions/hosted-runners/limits`. Defaults to false.",
},
"image_version": {
Type: schema.TypeString,
Optional: true,
- Description: "The version of the runner image to deploy. This is relevant only for runners using custom images.",
+ Description: "The version of the runner image to deploy. This is only relevant for runners using custom images.",
},
"image_gen": {
Type: schema.TypeBool,
@@ -116,14 +122,10 @@ func resourceGithubActionsHostedRunner() *schema.Resource {
Computed: true,
Description: "Current status of the runner.",
},
- "platform": {
- Type: schema.TypeString,
- Computed: true,
- Description: "Platform of the runner.",
- },
"machine_size_details": {
- Type: schema.TypeList,
- Computed: true,
+ Type: schema.TypeList,
+ Computed: true,
+ Description: "Detailed machine size specifications.",
Elem: &schema.Resource{
Schema: map[string]*schema.Schema{
"id": {
@@ -148,11 +150,16 @@ func resourceGithubActionsHostedRunner() *schema.Resource {
},
},
},
- Description: "Detailed machine size specifications.",
+ },
+ "platform": {
+ Type: schema.TypeString,
+ Computed: true,
+ Description: "Platform of the runner.",
},
"public_ips": {
- Type: schema.TypeList,
- Computed: true,
+ Type: schema.TypeList,
+ Computed: true,
+ Description: "List of public IP ranges assigned to this runner.",
Elem: &schema.Resource{
Schema: map[string]*schema.Schema{
"enabled": {
@@ -172,7 +179,6 @@ func resourceGithubActionsHostedRunner() *schema.Resource {
},
},
},
- Description: "List of public IP ranges assigned to this runner.",
},
"last_active_on": {
Type: schema.TypeString,
@@ -183,366 +189,271 @@ func resourceGithubActionsHostedRunner() *schema.Resource {
}
}
-func expandImage(imageList []any) map[string]any {
- if len(imageList) == 0 {
- return nil
- }
-
- imageMap := imageList[0].(map[string]any)
- result := make(map[string]any)
-
- if id, ok := imageMap["id"].(string); ok {
- result["id"] = id
- }
- if source, ok := imageMap["source"].(string); ok {
- result["source"] = source
- }
-
- return result
-}
-
-func flattenImage(image map[string]any) []any {
- if image == nil {
- return []any{}
- }
-
- result := make(map[string]any)
-
- // Handle id as either string or number
- if id, ok := image["id"].(string); ok {
- result["id"] = id
- } else if id, ok := image["id"].(float64); ok {
- result["id"] = fmt.Sprintf("%.0f", id)
- }
-
- if source, ok := image["source"].(string); ok {
- result["source"] = source
- }
- if size, ok := image["size"].(float64); ok {
- result["size_gb"] = int(size)
+func resourceGithubActionsHostedRunnerCreate(ctx context.Context, d *schema.ResourceData, m any) diag.Diagnostics {
+ err := checkOrganization(m)
+ if err != nil {
+ return diag.FromErr(err)
}
- return []any{result}
-}
+ meta, _ := m.(*Owner)
+ client := meta.v3client
+ orgName := meta.name
-func flattenMachineSizeDetails(details map[string]any) []any {
- if details == nil {
- return []any{}
+ runnerName, _ := d.Get("name").(string)
+ runnerImageDetails, _ := d.Get("image").([]any)
+ runnerSize, _ := d.Get("size").(string)
+ runnerGroupID, _ := d.Get("runner_group_id").(int)
+ expandedImageDetails, err := expandImage(runnerImageDetails)
+ if err != nil {
+ return diag.FromErr(err)
}
- result := make(map[string]any)
- if id, ok := details["id"].(string); ok {
- result["id"] = id
- }
- if cpuCores, ok := details["cpu_cores"].(float64); ok {
- result["cpu_cores"] = int(cpuCores)
- }
- if memoryGB, ok := details["memory_gb"].(float64); ok {
- result["memory_gb"] = int(memoryGB)
- }
- if storageGB, ok := details["storage_gb"].(float64); ok {
- result["storage_gb"] = int(storageGB)
+ req := github.CreateHostedRunnerRequest{
+ Name: runnerName,
+ Image: *expandedImageDetails,
+ Size: runnerSize,
+ RunnerGroupID: int64(runnerGroupID),
}
- return []any{result}
-}
-
-func flattenPublicIPs(ips []any) []any {
- if ips == nil {
- return []any{}
+ if v, ok := d.GetOk("maximum_runners"); ok {
+ maxRunners, _ := v.(int)
+ req.MaximumRunners = new(int64(maxRunners))
}
- result := make([]any, 0, len(ips))
- for _, ip := range ips {
- ipMap, ok := ip.(map[string]any)
- if !ok {
- continue
- }
+ publicIPEnabled, _ := d.Get("public_ip_enabled").(bool)
+ req.EnableStaticIP = new(publicIPEnabled)
- ipResult := make(map[string]any)
- if enabled, ok := ipMap["enabled"].(bool); ok {
- ipResult["enabled"] = enabled
- }
- if prefix, ok := ipMap["prefix"].(string); ok {
- ipResult["prefix"] = prefix
- }
- if length, ok := ipMap["length"].(float64); ok {
- ipResult["length"] = int(length)
- }
- result = append(result, ipResult)
+ if v, ok := d.GetOk("image_version"); ok {
+ runnerImageVersion, _ := v.(string)
+ req.Image.Version = new(runnerImageVersion)
}
- return result
-}
+ useRunnerForImageGen, _ := d.Get("image_gen").(bool)
+ req.ImageGen = new(useRunnerForImageGen)
-func resourceGithubActionsHostedRunnerCreate(d *schema.ResourceData, meta any) error {
- err := checkOrganization(meta)
+ runner, _, err := client.Actions.CreateHostedRunner(ctx, orgName, req)
if err != nil {
- return err
- }
-
- client := meta.(*Owner).v3client
- orgName := meta.(*Owner).name
- ctx := context.Background()
-
- // Build request payload
- payload := map[string]any{
- "name": d.Get("name").(string),
- "image": expandImage(d.Get("image").([]any)),
- "size": d.Get("size").(string),
- "runner_group_id": d.Get("runner_group_id").(int),
- }
-
- if v, ok := d.GetOk("maximum_runners"); ok {
- payload["maximum_runners"] = v.(int)
+ return diag.FromErr(err)
}
- if v, ok := d.GetOk("public_ip_enabled"); ok {
- payload["enable_static_ip"] = v.(bool)
- }
+ d.SetId(strconv.Itoa(int(runner.GetID())))
- if v, ok := d.GetOk("image_version"); ok {
- payload["image_version"] = v.(string)
+ if err := d.Set("status", runner.GetStatus()); err != nil {
+ return diag.FromErr(err)
}
-
- if v, ok := d.GetOk("image_gen"); ok {
- payload["image_gen"] = v.(bool)
+ if err := d.Set("platform", runner.GetPlatform()); err != nil {
+ return diag.FromErr(err)
}
-
- // Create HTTP request
- req, err := client.NewRequest(ctx, "POST", fmt.Sprintf("orgs/%s/actions/hosted-runners", orgName), payload)
- if err != nil {
- return err
+ if err := d.Set("last_active_on", runner.GetLastActiveOn().Format(time.RFC3339)); err != nil {
+ return diag.FromErr(err)
}
- var runner map[string]any
- _, err = client.Do(req, &runner)
- if err != nil {
- var acceptedErr *github.AcceptedError
- if !errors.As(err, &acceptedErr) {
- return err
+ if machineSizeDetails := runner.GetMachineSizeDetails(); machineSizeDetails != nil {
+ if err := d.Set("machine_size_details", flattenMachineSizeDetails(machineSizeDetails)); err != nil {
+ return diag.FromErr(err)
}
}
- if runner == nil {
- return fmt.Errorf("no runner data returned from API")
+ if err := d.Set("maximum_runners", runner.GetMaximumRunners()); err != nil {
+ return diag.FromErr(err)
}
- // Set the ID
- if id, ok := runner["id"].(float64); ok {
- d.SetId(strconv.Itoa(int(id)))
- } else {
- return fmt.Errorf("failed to get runner ID from response: %+v", runner)
+ if err := d.Set("public_ips", flattenPublicIPs(runner.GetPublicIPs())); err != nil {
+ return diag.FromErr(err)
}
- return resourceGithubActionsHostedRunnerRead(d, meta)
+ return nil
}
-func resourceGithubActionsHostedRunnerRead(d *schema.ResourceData, meta any) error {
- err := checkOrganization(meta)
+func resourceGithubActionsHostedRunnerRead(ctx context.Context, d *schema.ResourceData, m any) diag.Diagnostics {
+ err := checkOrganization(m)
if err != nil {
- return err
+ return diag.FromErr(err)
}
- client := meta.(*Owner).v3client
- orgName := meta.(*Owner).name
- runnerID := d.Id()
- ctx := context.WithValue(context.Background(), ctxId, runnerID)
+ meta, _ := m.(*Owner)
+ client := meta.v3client
+ orgName := meta.name
- // Create GET request
- req, err := client.NewRequest(ctx, "GET", fmt.Sprintf("orgs/%s/actions/hosted-runners/%s", orgName, runnerID), nil)
+ runnerIDStr := d.Id()
+ runnerID, err := strconv.ParseInt(runnerIDStr, 10, 64)
if err != nil {
- return err
+ return diag.FromErr(err)
}
- var runner map[string]any
- _, err = client.Do(req, &runner)
+ runner, _, err := client.Actions.GetHostedRunner(ctx, orgName, runnerID)
if err != nil {
- var ghErr *github.ErrorResponse
- if errors.As(err, &ghErr) {
- if ghErr.Response.StatusCode == http.StatusNotFound {
- log.Printf("[WARN] Removing hosted runner %s from state because it no longer exists in GitHub", runnerID)
- d.SetId("")
- return nil
- }
+ if err, ok := errors.AsType[*github.ErrorResponse](err); ok && err.Response.StatusCode == http.StatusNotFound {
+ tflog.Warn(ctx, "Removing hosted runner from state because it no longer exists in GitHub", map[string]any{"runner_id": runnerIDStr})
+ d.SetId("")
+ return nil
}
- return err
+ return diag.FromErr(err)
}
- if runner == nil {
- return fmt.Errorf("no runner data returned from API")
+ if err := d.Set("name", runner.GetName()); err != nil {
+ return diag.FromErr(err)
}
-
- if name, ok := runner["name"].(string); ok {
- if err := d.Set("name", name); err != nil {
- return err
- }
- }
- if status, ok := runner["status"].(string); ok {
- if err := d.Set("status", status); err != nil {
- return err
- }
+ if err := d.Set("status", runner.GetStatus()); err != nil {
+ return diag.FromErr(err)
}
- if platform, ok := runner["platform"].(string); ok {
- if err := d.Set("platform", platform); err != nil {
- return err
- }
+ if err := d.Set("platform", runner.GetPlatform()); err != nil {
+ return diag.FromErr(err)
}
- if lastActiveOn, ok := runner["last_active_on"].(string); ok {
- if err := d.Set("last_active_on", lastActiveOn); err != nil {
- return err
- }
+ if err := d.Set("last_active_on", runner.GetLastActiveOn().Format(time.RFC3339)); err != nil {
+ return diag.FromErr(err)
}
- if publicIPEnabled, ok := runner["public_ip_enabled"].(bool); ok {
- if err := d.Set("public_ip_enabled", publicIPEnabled); err != nil {
- return err
- }
+ if err := d.Set("public_ip_enabled", runner.GetPublicIPEnabled()); err != nil {
+ return diag.FromErr(err)
}
- if image, ok := runner["image"].(map[string]any); ok {
- if err := d.Set("image", flattenImage(image)); err != nil {
- return err
- }
+ if err := d.Set("image", flattenImage(runner.GetImageDetails())); err != nil {
+ return diag.FromErr(err)
}
- if machineSizeDetails, ok := runner["machine_size_details"].(map[string]any); ok {
- if err := d.Set("size", machineSizeDetails["id"]); err != nil {
- return err
+ if machineSizeDetails := runner.GetMachineSizeDetails(); machineSizeDetails != nil {
+ if err := d.Set("size", machineSizeDetails.GetID()); err != nil {
+ return diag.FromErr(err)
}
if err := d.Set("machine_size_details", flattenMachineSizeDetails(machineSizeDetails)); err != nil {
- return err
+ return diag.FromErr(err)
}
}
- if runnerGroupID, ok := runner["runner_group_id"].(float64); ok {
- if err := d.Set("runner_group_id", int(runnerGroupID)); err != nil {
- return err
- }
+ if err := d.Set("runner_group_id", runner.GetRunnerGroupID()); err != nil {
+ return diag.FromErr(err)
}
- if maxRunners, ok := runner["maximum_runners"].(float64); ok {
- if err := d.Set("maximum_runners", int(maxRunners)); err != nil {
- return err
- }
+ if err := d.Set("maximum_runners", runner.GetMaximumRunners()); err != nil {
+ return diag.FromErr(err)
}
- if publicIPs, ok := runner["public_ips"].([]any); ok {
- if err := d.Set("public_ips", flattenPublicIPs(publicIPs)); err != nil {
- return err
- }
+ if err := d.Set("public_ips", flattenPublicIPs(runner.GetPublicIPs())); err != nil {
+ return diag.FromErr(err)
}
- if imageGen, ok := runner["image_gen"].(bool); ok {
- if err := d.Set("image_gen", imageGen); err != nil {
- return err
- }
- }
+ // TODO: Uncomment when go-github supports image_gen field in the HostedRunner struct
+ // if err := d.Set("image_gen", runner.GetImageGen()); err != nil {
+ // return diag.FromErr(err)
+ // }
return nil
}
-func resourceGithubActionsHostedRunnerUpdate(d *schema.ResourceData, meta any) error {
- err := checkOrganization(meta)
+func resourceGithubActionsHostedRunnerUpdate(ctx context.Context, d *schema.ResourceData, m any) diag.Diagnostics {
+ err := checkOrganization(m)
if err != nil {
- return err
+ return diag.FromErr(err)
}
- client := meta.(*Owner).v3client
- orgName := meta.(*Owner).name
- runnerID := d.Id()
- ctx := context.WithValue(context.Background(), ctxId, runnerID)
+ // Only update the runner if any of the configured fields have changed
+ if !d.HasChanges("name", "size", "runner_group_id", "maximum_runners", "public_ip_enabled", "image_version") {
+ return nil
+ }
- payload := make(map[string]any)
+ meta, _ := m.(*Owner)
+ client := meta.v3client
+ orgName := meta.name
- if d.HasChange("name") {
- payload["name"] = d.Get("name").(string)
+ runnerID, err := strconv.ParseInt(d.Id(), 10, 64)
+ if err != nil {
+ return diag.FromErr(err)
}
- if d.HasChange("size") {
- payload["size"] = d.Get("size").(string)
+
+ name, _ := d.Get("name").(string)
+ size, _ := d.Get("size").(string)
+ runnerGroupID, _ := d.Get("runner_group_id").(int)
+ maximumRunners, _ := d.Get("maximum_runners").(int)
+ publicIPEnabled, _ := d.Get("public_ip_enabled").(bool)
+
+ req := github.UpdateHostedRunnerRequest{
+ Name: new(name),
+ Size: new(size),
+ RunnerGroupID: new(int64(runnerGroupID)),
+ MaximumRunners: new(int64(maximumRunners)),
+ EnableStaticIP: new(publicIPEnabled),
}
- if d.HasChange("runner_group_id") {
- payload["runner_group_id"] = d.Get("runner_group_id").(int)
+
+ // image_version is only settable for runners with a custom image, so we only include it in the request if it has changed
+ if d.HasChange("image_version") {
+ imageVersion, _ := d.Get("image_version").(string)
+ req.ImageVersion = new(imageVersion)
}
- if d.HasChange("maximum_runners") {
- payload["maximum_runners"] = d.Get("maximum_runners").(int)
+
+ runner, _, err := client.Actions.UpdateHostedRunner(ctx, orgName, runnerID, req)
+ if err != nil {
+ if _, ok := errors.AsType[*github.AcceptedError](err); !ok {
+ return diag.FromErr(err)
+ }
}
- if d.HasChange("public_ip_enabled") {
- payload["enable_static_ip"] = d.Get("public_ip_enabled").(bool)
+
+ if err := d.Set("status", runner.GetStatus()); err != nil {
+ return diag.FromErr(err)
}
- if d.HasChange("image_version") {
- payload["image_version"] = d.Get("image_version").(string)
+ if err := d.Set("platform", runner.GetPlatform()); err != nil {
+ return diag.FromErr(err)
+ }
+ if err := d.Set("last_active_on", runner.GetLastActiveOn().Format(time.RFC3339)); err != nil {
+ return diag.FromErr(err)
}
- if len(payload) == 0 {
- return resourceGithubActionsHostedRunnerRead(d, meta)
+ if machineSizeDetails := runner.GetMachineSizeDetails(); machineSizeDetails != nil {
+ if err := d.Set("machine_size_details", flattenMachineSizeDetails(machineSizeDetails)); err != nil {
+ return diag.FromErr(err)
+ }
}
- // Create PATCH request
- req, err := client.NewRequest(ctx, "PATCH", fmt.Sprintf("orgs/%s/actions/hosted-runners/%s", orgName, runnerID), payload)
- if err != nil {
- return err
+ if err := d.Set("maximum_runners", runner.GetMaximumRunners()); err != nil {
+ return diag.FromErr(err)
}
- var runner map[string]any
- _, err = client.Do(req, &runner)
- if err != nil {
- var acceptedErr *github.AcceptedError
- if !errors.As(err, &acceptedErr) {
- return err
- }
+ if err := d.Set("public_ips", flattenPublicIPs(runner.GetPublicIPs())); err != nil {
+ return diag.FromErr(err)
}
- return resourceGithubActionsHostedRunnerRead(d, meta)
+ return nil
}
-func resourceGithubActionsHostedRunnerDelete(d *schema.ResourceData, meta any) error {
- err := checkOrganization(meta)
+func resourceGithubActionsHostedRunnerDelete(ctx context.Context, d *schema.ResourceData, m any) diag.Diagnostics {
+ err := checkOrganization(m)
if err != nil {
- return err
+ return diag.FromErr(err)
}
- client := meta.(*Owner).v3client
- orgName := meta.(*Owner).name
- ctx := context.Background()
+ meta, _ := m.(*Owner)
+ client := meta.v3client
+ orgName := meta.name
- runnerID := d.Id()
-
- // Send DELETE request
- req, err := client.NewRequest(ctx, "DELETE", fmt.Sprintf("orgs/%s/actions/hosted-runners/%s", orgName, runnerID), nil)
+ runnerID, err := strconv.ParseInt(d.Id(), 10, 64)
if err != nil {
- return err
+ return diag.FromErr(err)
}
- resp, err := client.Do(req, nil)
+ _, resp, err := client.Actions.DeleteHostedRunner(ctx, orgName, runnerID)
if err != nil {
if resp != nil && resp.StatusCode == http.StatusNotFound {
return nil
}
- var acceptedErr *github.AcceptedError
- if errors.As(err, &acceptedErr) {
- return waitForRunnerDeletion(ctx, client, orgName, runnerID, d.Timeout(schema.TimeoutDelete))
+ if _, ok := errors.AsType[*github.AcceptedError](err); ok {
+ return diag.FromErr(waitForRunnerDeletion(ctx, client, orgName, runnerID, d.Timeout(schema.TimeoutDelete)))
}
- return err
+ return diag.FromErr(err)
}
if resp != nil && resp.StatusCode == http.StatusAccepted {
- return waitForRunnerDeletion(ctx, client, orgName, runnerID, d.Timeout(schema.TimeoutDelete))
+ return diag.FromErr(waitForRunnerDeletion(ctx, client, orgName, runnerID, d.Timeout(schema.TimeoutDelete)))
}
return nil
}
-func waitForRunnerDeletion(ctx context.Context, client *github.Client, orgName, runnerID string, timeout time.Duration) error {
+func waitForRunnerDeletion(ctx context.Context, client *github.Client, orgName string, runnerID int64, timeout time.Duration) error {
conf := &retry.StateChangeConf{
- Pending: []string{"deleting", "active"},
+ Pending: []string{"deleting", "active", "ready", "provisioning"},
Target: []string{"deleted"},
Refresh: func() (any, string, error) {
- req, err := client.NewRequest(ctx, "GET", fmt.Sprintf("orgs/%s/actions/hosted-runners/%s", orgName, runnerID), nil)
- if err != nil {
- return nil, "", err
- }
+ _, resp, err := client.Actions.GetHostedRunner(ctx, orgName, runnerID)
- resp, err := client.Do(req, nil)
if resp != nil && resp.StatusCode == http.StatusNotFound {
return "deleted", "deleted", nil
}
@@ -561,3 +472,91 @@ func waitForRunnerDeletion(ctx context.Context, client *github.Client, orgName,
_, err := conf.WaitForStateContext(ctx)
return err
}
+
+func expandImage(imageList []any) (*github.HostedRunnerImage, error) {
+ if len(imageList) == 0 {
+ return &github.HostedRunnerImage{}, nil
+ }
+ runnerImage := &github.HostedRunnerImage{}
+
+ imageMap, ok := imageList[0].(map[string]any)
+ if !ok {
+ return nil, fmt.Errorf("expected map[string]any, got %T", imageList[0])
+ }
+
+ if id, ok := imageMap["id"].(string); ok {
+ runnerImage.ID = id
+ }
+ if source, ok := imageMap["source"].(string); ok {
+ runnerImage.Source = source
+ }
+
+ return runnerImage, nil
+}
+
+func flattenImage(image *github.HostedRunnerImageDetail) []any {
+ if image == nil {
+ return []any{}
+ }
+
+ result := make(map[string]any)
+
+ result["id"] = image.GetID()
+ result["source"] = image.GetSource()
+ result["size_gb"] = image.GetSizeGB()
+
+ return []any{result}
+}
+
+func flattenMachineSizeDetails(details *github.HostedRunnerMachineSpec) []any {
+ if details == nil {
+ return []any{}
+ }
+
+ result := make(map[string]any)
+ result["id"] = details.GetID()
+ result["cpu_cores"] = details.GetCPUCores()
+ result["memory_gb"] = details.GetMemoryGB()
+ result["storage_gb"] = details.GetStorageGB()
+
+ return []any{result}
+}
+
+func flattenPublicIPs(ips []*github.HostedRunnerPublicIP) []any {
+ if ips == nil {
+ return []any{}
+ }
+
+ result := make([]any, 0, len(ips))
+ for _, ip := range ips {
+ ipResult := make(map[string]any)
+ ipResult["enabled"] = ip.GetEnabled()
+ ipResult["prefix"] = ip.GetPrefix()
+ ipResult["length"] = ip.GetLength()
+ result = append(result, ipResult)
+ }
+
+ return result
+}
+
+func resourceGithubActionsHostedRunnerValidation(ctx context.Context, d *schema.ResourceDiff, meta any) error {
+ // validates that image_version is only set for images with source "custom"
+ if d.HasChange("image_version") {
+ imageList, ok := d.GetOk("image")
+ imageSeq, okSeq := imageList.([]any)
+ if !ok || !okSeq || len(imageSeq) == 0 {
+ return fmt.Errorf("`image_version` can only be set when `image` is configured")
+ }
+
+ imageMap, ok := imageSeq[0].(map[string]any)
+ if !ok {
+ return fmt.Errorf("unexpected type for image: %T", imageSeq[0])
+ }
+
+ source, ok := imageMap["source"].(string)
+ if !ok || source != "custom" {
+ return fmt.Errorf("`image_version` can only be set when `image[0].source` is 'custom'")
+ }
+ }
+ return nil
+}
diff --git a/github/resource_github_actions_hosted_runner_test.go b/github/resource_github_actions_hosted_runner_test.go
index 7b6a7f83fc..2a8f2c8c09 100644
--- a/github/resource_github_actions_hosted_runner_test.go
+++ b/github/resource_github_actions_hosted_runner_test.go
@@ -2,28 +2,31 @@ package github
import (
"fmt"
+ "regexp"
"testing"
+ "github.com/hashicorp/terraform-plugin-testing/compare"
"github.com/hashicorp/terraform-plugin-testing/helper/acctest"
"github.com/hashicorp/terraform-plugin-testing/helper/resource"
+ "github.com/hashicorp/terraform-plugin-testing/knownvalue"
+ "github.com/hashicorp/terraform-plugin-testing/plancheck"
+ "github.com/hashicorp/terraform-plugin-testing/statecheck"
+ "github.com/hashicorp/terraform-plugin-testing/tfjsonpath"
)
func TestAccGithubActionsHostedRunner(t *testing.T) {
t.Parallel()
- randomID := acctest.RandStringFromCharSet(5, acctest.CharSetAlphaNum)
-
- t.Run("creates hosted runners without error", func(t *testing.T) {
+ t.Run("creates_hosted_runners_without_error", func(t *testing.T) {
t.Parallel()
- config := fmt.Sprintf(`
- resource "github_actions_runner_group" "test" {
- name = "tf-acc-test-group-%s"
- visibility = "all"
- }
+ runnerGroup := mustCreateTestOrganizationActionsRunnerGroup(t)
+ randomID := acctest.RandString(5)
+ hostedRunnerName := fmt.Sprintf("%srunner-%s", testResourcePrefix, randomID)
+ config := fmt.Sprintf(`
resource "github_actions_hosted_runner" "test" {
- name = "tf-acc-test-%s"
+ name = "%s"
image {
id = "2306"
@@ -31,52 +34,9 @@ func TestAccGithubActionsHostedRunner(t *testing.T) {
}
size = "4-core"
- runner_group_id = github_actions_runner_group.test.id
+ runner_group_id = "%d"
}
- `, randomID, randomID)
-
- check := resource.ComposeTestCheckFunc(
- resource.TestCheckResourceAttr(
- "github_actions_hosted_runner.test", "name",
- fmt.Sprintf("tf-acc-test-%s", randomID),
- ),
- resource.TestCheckResourceAttr(
- "github_actions_hosted_runner.test", "size",
- "4-core",
- ),
- resource.TestCheckResourceAttr(
- "github_actions_hosted_runner.test", "image.0.id",
- "2306",
- ),
- resource.TestCheckResourceAttr(
- "github_actions_hosted_runner.test", "image.0.source",
- "github",
- ),
- resource.TestCheckResourceAttrSet(
- "github_actions_hosted_runner.test", "id",
- ),
- resource.TestCheckResourceAttrSet(
- "github_actions_hosted_runner.test", "status",
- ),
- resource.TestCheckResourceAttrSet(
- "github_actions_hosted_runner.test", "platform",
- ),
- resource.TestCheckResourceAttrSet(
- "github_actions_hosted_runner.test", "image.0.size_gb",
- ),
- resource.TestCheckResourceAttrSet(
- "github_actions_hosted_runner.test", "machine_size_details.0.id",
- ),
- resource.TestCheckResourceAttrSet(
- "github_actions_hosted_runner.test", "machine_size_details.0.cpu_cores",
- ),
- resource.TestCheckResourceAttrSet(
- "github_actions_hosted_runner.test", "machine_size_details.0.memory_gb",
- ),
- resource.TestCheckResourceAttrSet(
- "github_actions_hosted_runner.test", "machine_size_details.0.storage_gb",
- ),
- )
+ `, hostedRunnerName, runnerGroup.GetID())
resource.Test(t, resource.TestCase{
PreCheck: func() { skipUnlessHasPaidOrgs(t) },
@@ -84,23 +44,31 @@ func TestAccGithubActionsHostedRunner(t *testing.T) {
Steps: []resource.TestStep{
{
Config: config,
- Check: check,
+ ConfigStateChecks: []statecheck.StateCheck{
+ statecheck.ExpectKnownValue("github_actions_hosted_runner.test", tfjsonpath.New("id"), knownvalue.NotNull()),
+ statecheck.ExpectKnownValue("github_actions_hosted_runner.test", tfjsonpath.New("status"), knownvalue.NotNull()),
+ statecheck.ExpectKnownValue("github_actions_hosted_runner.test", tfjsonpath.New("platform"), knownvalue.NotNull()),
+ statecheck.ExpectKnownValue("github_actions_hosted_runner.test", tfjsonpath.New("image").AtSliceIndex(0).AtMapKey("size_gb"), knownvalue.NotNull()),
+ statecheck.ExpectKnownValue("github_actions_hosted_runner.test", tfjsonpath.New("machine_size_details").AtSliceIndex(0).AtMapKey("id"), knownvalue.NotNull()),
+ statecheck.ExpectKnownValue("github_actions_hosted_runner.test", tfjsonpath.New("machine_size_details").AtSliceIndex(0).AtMapKey("cpu_cores"), knownvalue.NotNull()),
+ statecheck.ExpectKnownValue("github_actions_hosted_runner.test", tfjsonpath.New("machine_size_details").AtSliceIndex(0).AtMapKey("memory_gb"), knownvalue.NotNull()),
+ statecheck.ExpectKnownValue("github_actions_hosted_runner.test", tfjsonpath.New("machine_size_details").AtSliceIndex(0).AtMapKey("storage_gb"), knownvalue.NotNull()),
+ },
},
},
})
})
- t.Run("creates hosted runner with optional parameters", func(t *testing.T) {
+ t.Run("creates_hosted_runner_with_optional_parameters", func(t *testing.T) {
t.Parallel()
- config := fmt.Sprintf(`
- resource "github_actions_runner_group" "test" {
- name = "tf-acc-test-group-%s"
- visibility = "all"
- }
+ runnerGroup := mustCreateTestOrganizationActionsRunnerGroup(t)
+ randomID := acctest.RandString(5)
+ hostedRunnerName := fmt.Sprintf("%srunner-optional-%s", testResourcePrefix, randomID)
+ config := fmt.Sprintf(`
resource "github_actions_hosted_runner" "test" {
- name = "tf-acc-test-optional-%s"
+ name = "%s"
image {
id = "2306"
@@ -108,30 +76,11 @@ func TestAccGithubActionsHostedRunner(t *testing.T) {
}
size = "2-core"
- runner_group_id = github_actions_runner_group.test.id
- maximum_runners = 5
+ runner_group_id = "%d"
+ maximum_runners = 2
public_ip_enabled = true
}
- `, randomID, randomID)
-
- check := resource.ComposeTestCheckFunc(
- resource.TestCheckResourceAttr(
- "github_actions_hosted_runner.test", "name",
- fmt.Sprintf("tf-acc-test-optional-%s", randomID),
- ),
- resource.TestCheckResourceAttr(
- "github_actions_hosted_runner.test", "size",
- "2-core",
- ),
- resource.TestCheckResourceAttr(
- "github_actions_hosted_runner.test", "maximum_runners",
- "5",
- ),
- resource.TestCheckResourceAttr(
- "github_actions_hosted_runner.test", "public_ip_enabled",
- "true",
- ),
- )
+ `, hostedRunnerName, runnerGroup.GetID())
resource.Test(t, resource.TestCase{
PreCheck: func() { skipUnlessHasPaidOrgs(t) },
@@ -139,23 +88,21 @@ func TestAccGithubActionsHostedRunner(t *testing.T) {
Steps: []resource.TestStep{
{
Config: config,
- Check: check,
},
},
})
})
- t.Run("updates hosted runner configuration", func(t *testing.T) {
+ t.Run("updates_hosted_runner_configuration", func(t *testing.T) {
t.Parallel()
- configBefore := fmt.Sprintf(`
- resource "github_actions_runner_group" "test" {
- name = "tf-acc-test-group-%s"
- visibility = "all"
- }
+ runnerGroup := mustCreateTestOrganizationActionsRunnerGroup(t)
+ randomID := acctest.RandString(5)
+ hostedRunnerName := fmt.Sprintf("%srunner-update-%s", testResourcePrefix, randomID)
+ configTmpl := `
resource "github_actions_hosted_runner" "test" {
- name = "tf-acc-test-update-%s"
+ name = "%s"
image {
id = "2306"
@@ -163,167 +110,97 @@ func TestAccGithubActionsHostedRunner(t *testing.T) {
}
size = "4-core"
- runner_group_id = github_actions_runner_group.test.id
- maximum_runners = 3
- }
- `, randomID, randomID)
-
- configAfter := fmt.Sprintf(`
- resource "github_actions_runner_group" "test" {
- name = "tf-acc-test-group-%s"
- visibility = "all"
+ runner_group_id = "%d"
+ maximum_runners = %d
}
+ `
- resource "github_actions_hosted_runner" "test" {
- name = "tf-acc-test-update-%s-updated"
-
- image {
- id = "2306"
- source = "github"
- }
-
- size = "4-core"
- runner_group_id = github_actions_runner_group.test.id
- maximum_runners = 5
- }
- `, randomID, randomID)
-
- checkBefore := resource.ComposeTestCheckFunc(
- resource.TestCheckResourceAttr(
- "github_actions_hosted_runner.test", "name",
- fmt.Sprintf("tf-acc-test-update-%s", randomID),
- ),
- resource.TestCheckResourceAttr(
- "github_actions_hosted_runner.test", "size",
- "4-core",
- ),
- resource.TestCheckResourceAttr(
- "github_actions_hosted_runner.test", "maximum_runners",
- "3",
- ),
- )
-
- checkAfter := resource.ComposeTestCheckFunc(
- resource.TestCheckResourceAttr(
- "github_actions_hosted_runner.test", "name",
- fmt.Sprintf("tf-acc-test-update-%s-updated", randomID),
- ),
- resource.TestCheckResourceAttr(
- "github_actions_hosted_runner.test", "size",
- "4-core",
- ),
- resource.TestCheckResourceAttr(
- "github_actions_hosted_runner.test", "maximum_runners",
- "5",
- ),
- )
-
+ compareMaxRunnersUpdated := statecheck.CompareValue(compare.ValuesDiffer())
resource.Test(t, resource.TestCase{
PreCheck: func() { skipUnlessHasPaidOrgs(t) },
ProviderFactories: providerFactories,
Steps: []resource.TestStep{
{
- Config: configBefore,
- Check: checkBefore,
+ Config: fmt.Sprintf(configTmpl, hostedRunnerName, runnerGroup.GetID(), 2),
+ ConfigStateChecks: []statecheck.StateCheck{
+ compareMaxRunnersUpdated.AddStateValue("github_actions_hosted_runner.test", tfjsonpath.New("maximum_runners")),
+ },
},
{
- Config: configAfter,
- Check: checkAfter,
+ Config: fmt.Sprintf(configTmpl, fmt.Sprintf("%s-updated", hostedRunnerName), runnerGroup.GetID(), 3),
+ ConfigPlanChecks: resource.ConfigPlanChecks{
+ PreApply: []plancheck.PlanCheck{
+ plancheck.ExpectResourceAction("github_actions_hosted_runner.test", plancheck.ResourceActionUpdate),
+ },
+ },
+ ConfigStateChecks: []statecheck.StateCheck{
+ statecheck.ExpectKnownValue("github_actions_hosted_runner.test", tfjsonpath.New("name"), knownvalue.StringExact(fmt.Sprintf("%s-updated", hostedRunnerName))),
+ compareMaxRunnersUpdated.AddStateValue("github_actions_hosted_runner.test", tfjsonpath.New("maximum_runners")),
+ },
},
},
})
})
- t.Run("updates size field", func(t *testing.T) {
+ t.Run("updates_size_field", func(t *testing.T) {
t.Parallel()
- configBefore := fmt.Sprintf(`
- resource "github_actions_runner_group" "test" {
- name = "tf-acc-test-group-%s"
- visibility = "all"
- }
-
- resource "github_actions_hosted_runner" "test" {
- name = "tf-acc-test-size-%s"
-
- image {
- id = "2306"
- source = "github"
- }
-
- size = "4-core"
- runner_group_id = github_actions_runner_group.test.id
- }
- `, randomID, randomID)
-
- configAfter := fmt.Sprintf(`
- resource "github_actions_runner_group" "test" {
- name = "tf-acc-test-group-%s"
- visibility = "all"
- }
+ runnerGroup := mustCreateTestOrganizationActionsRunnerGroup(t)
+ randomID := acctest.RandString(5)
+ hostedRunnerName := fmt.Sprintf("%srunner-size-%s", testResourcePrefix, randomID)
+ configTmpl := `
resource "github_actions_hosted_runner" "test" {
- name = "tf-acc-test-size-%s"
+ name = "%s"
image {
id = "2306"
source = "github"
}
- size = "8-core"
- runner_group_id = github_actions_runner_group.test.id
+ size = "%s"
+ runner_group_id = "%d"
}
- `, randomID, randomID)
-
- checkBefore := resource.ComposeTestCheckFunc(
- resource.TestCheckResourceAttr(
- "github_actions_hosted_runner.test", "size",
- "4-core",
- ),
- resource.TestCheckResourceAttr(
- "github_actions_hosted_runner.test", "machine_size_details.0.cpu_cores",
- "4",
- ),
- )
-
- checkAfter := resource.ComposeTestCheckFunc(
- resource.TestCheckResourceAttr(
- "github_actions_hosted_runner.test", "size",
- "8-core",
- ),
- resource.TestCheckResourceAttr(
- "github_actions_hosted_runner.test", "machine_size_details.0.cpu_cores",
- "8",
- ),
- )
+ `
+ compareSizeUpdated := statecheck.CompareValue(compare.ValuesDiffer())
resource.Test(t, resource.TestCase{
PreCheck: func() { skipUnlessHasPaidOrgs(t) },
ProviderFactories: providerFactories,
Steps: []resource.TestStep{
{
- Config: configBefore,
- Check: checkBefore,
+ Config: fmt.Sprintf(configTmpl, hostedRunnerName, "4-core", runnerGroup.GetID()),
+ ConfigStateChecks: []statecheck.StateCheck{
+ compareSizeUpdated.AddStateValue("github_actions_hosted_runner.test", tfjsonpath.New("size")),
+ statecheck.ExpectKnownValue("github_actions_hosted_runner.test", tfjsonpath.New("machine_size_details").AtSliceIndex(0).AtMapKey("cpu_cores"), knownvalue.Int64Exact(4)),
+ },
},
{
- Config: configAfter,
- Check: checkAfter,
+ Config: fmt.Sprintf(configTmpl, hostedRunnerName, "8-core", runnerGroup.GetID()),
+ ConfigPlanChecks: resource.ConfigPlanChecks{
+ PreApply: []plancheck.PlanCheck{
+ plancheck.ExpectResourceAction("github_actions_hosted_runner.test", plancheck.ResourceActionUpdate),
+ },
+ },
+ ConfigStateChecks: []statecheck.StateCheck{
+ compareSizeUpdated.AddStateValue("github_actions_hosted_runner.test", tfjsonpath.New("size")),
+ statecheck.ExpectKnownValue("github_actions_hosted_runner.test", tfjsonpath.New("machine_size_details").AtSliceIndex(0).AtMapKey("cpu_cores"), knownvalue.Int64Exact(8)),
+ },
},
},
})
})
- t.Run("imports hosted runner", func(t *testing.T) {
+ t.Run("imports_hosted_runner", func(t *testing.T) {
t.Parallel()
- config := fmt.Sprintf(`
- resource "github_actions_runner_group" "test" {
- name = "tf-acc-test-group-%s"
- visibility = "all"
- }
+ runnerGroup := mustCreateTestOrganizationActionsRunnerGroup(t)
+ randomID := acctest.RandString(5)
+ hostedRunnerName := fmt.Sprintf("%srunner-import-%s", testResourcePrefix, randomID)
+ config := fmt.Sprintf(`
resource "github_actions_hosted_runner" "test" {
- name = "tf-acc-test-import-%s"
+ name = "%s"
image {
id = "2306"
@@ -331,19 +208,9 @@ func TestAccGithubActionsHostedRunner(t *testing.T) {
}
size = "4-core"
- runner_group_id = github_actions_runner_group.test.id
+ runner_group_id = "%d"
}
- `, randomID, randomID)
-
- check := resource.ComposeTestCheckFunc(
- resource.TestCheckResourceAttrSet(
- "github_actions_hosted_runner.test", "id",
- ),
- resource.TestCheckResourceAttr(
- "github_actions_hosted_runner.test", "name",
- fmt.Sprintf("tf-acc-test-import-%s", randomID),
- ),
- )
+ `, hostedRunnerName, runnerGroup.GetID())
resource.Test(t, resource.TestCase{
PreCheck: func() { skipUnlessHasPaidOrgs(t) },
@@ -351,29 +218,27 @@ func TestAccGithubActionsHostedRunner(t *testing.T) {
Steps: []resource.TestStep{
{
Config: config,
- Check: check,
},
{
ResourceName: "github_actions_hosted_runner.test",
ImportState: true,
ImportStateVerify: true,
- ImportStateVerifyIgnore: []string{"image", "image_gen"},
+ ImportStateVerifyIgnore: []string{"status", "image.0.size_gb", "image_gen"},
},
},
})
})
- t.Run("deletes hosted runner", func(t *testing.T) {
+ t.Run("deletes_hosted_runner", func(t *testing.T) {
t.Parallel()
- config := fmt.Sprintf(`
- resource "github_actions_runner_group" "test" {
- name = "tf-acc-test-group-%s"
- visibility = "all"
- }
+ runnerGroup := mustCreateTestOrganizationActionsRunnerGroup(t)
+ randomID := acctest.RandString(5)
+ hostedRunnerName := fmt.Sprintf("%srunner-delete-%s", testResourcePrefix, randomID)
+ config := fmt.Sprintf(`
resource "github_actions_hosted_runner" "test" {
- name = "tf-acc-test-delete-%s"
+ name = "%s"
image {
id = "2306"
@@ -381,9 +246,9 @@ func TestAccGithubActionsHostedRunner(t *testing.T) {
}
size = "4-core"
- runner_group_id = github_actions_runner_group.test.id
+ runner_group_id = "%d"
}
- `, randomID, randomID)
+ `, hostedRunnerName, runnerGroup.GetID())
resource.Test(t, resource.TestCase{
PreCheck: func() { skipUnlessHasPaidOrgs(t) },
@@ -391,20 +256,42 @@ func TestAccGithubActionsHostedRunner(t *testing.T) {
Steps: []resource.TestStep{
{
Config: config,
- Check: resource.ComposeTestCheckFunc(
- resource.TestCheckResourceAttrSet(
- "github_actions_hosted_runner.test", "id",
- ),
- ),
},
- // This step should successfully delete the runner
{
- Config: fmt.Sprintf(`
- resource "github_actions_runner_group" "test" {
- name = "tf-acc-test-group-%s"
- visibility = "all"
- }
- `, randomID),
+ Config: config,
+ Destroy: true,
+ },
+ },
+ })
+ })
+
+ t.Run("validates_image_version_only_allowed_custom_image", func(t *testing.T) {
+ t.Parallel()
+
+ runnerGroup := mustCreateTestOrganizationActionsRunnerGroup(t)
+ randomID := acctest.RandString(5)
+ hostedRunnerName := fmt.Sprintf("%simage-version-%s", testResourcePrefix, randomID)
+
+ config := fmt.Sprintf(`
+resource "github_actions_hosted_runner" "test" {
+ name = "%s"
+
+ image {
+ id = "2306"
+ source = "github"
+ }
+ image_version = "1.0.0"
+ size = "4-core"
+ runner_group_id = "%d"
+}`, hostedRunnerName, runnerGroup.GetID())
+
+ resource.Test(t, resource.TestCase{
+ PreCheck: func() { skipUnlessHasPaidOrgs(t) },
+ ProviderFactories: providerFactories,
+ Steps: []resource.TestStep{
+ {
+ Config: config,
+ ExpectError: regexp.MustCompile("`image_version` can only be set when `image\\[0\\].source` is 'custom'"),
},
},
})
diff --git a/templates/resources/actions_hosted_runner.md.tmpl b/templates/resources/actions_hosted_runner.md.tmpl
index 166b5c73e7..23de6892ba 100644
--- a/templates/resources/actions_hosted_runner.md.tmpl
+++ b/templates/resources/actions_hosted_runner.md.tmpl
@@ -1,86 +1,20 @@
---
page_title: "{{.Name}} ({{.Type}}) - {{.RenderedProviderName}}"
+subcategory: ""
description: |-
- Creates and manages GitHub-hosted runners within a GitHub organization
+{{ .Description | plainmarkdown | trimspace | prefixlines " " }}
---
# {{.Name}} ({{.Type}})
-This resource allows you to create and manage GitHub-hosted runners within your GitHub organization. You must have admin access to an organization to use this resource.
-
-GitHub-hosted runners are fully managed virtual machines that run your GitHub Actions workflows. Unlike self-hosted runners, GitHub handles the infrastructure, maintenance, and scaling.
-
-## Example Usage
-
-### Basic Usage
-
-{{ tffile "examples/resources/actions_hosted_runner/example_1.tf" }}
-
-### Advanced Usage with Optional Parameters
-
-{{ tffile "examples/resources/actions_hosted_runner/example_2.tf" }}
-
-## Argument Reference
-
-The following arguments are supported:
-
-- `name` - (Required) Name of the hosted runner. Must be between 1 and 64 characters and may only contain alphanumeric characters, '.', '-', and '_'.
-- `image` - (Required) Image configuration for the hosted runner. Cannot be changed after creation. Block supports:
- - `id` - (Required) The image ID. For GitHub-owned images, use numeric IDs like "2306" for Ubuntu Latest 24.04. To get available images, use the GitHub API: `GET /orgs/{org}/actions/hosted-runners/images/github-owned`.
- - `source` - (Optional) The image source. Valid values are "github", "partner", or "custom". Defaults to "github".
-- `size` - (Required) Machine size for the hosted runner (e.g., "4-core", "8-core"). Can be updated to scale the runner. To list available sizes, use the GitHub API: `GET /orgs/{org}/actions/hosted-runners/machine-sizes`.
-- `runner_group_id` - (Required) The ID of the runner group to assign this runner to.
-- `maximum_runners` - (Optional) Maximum number of runners to scale up to. Runners will not auto-scale above this number. Use this setting to limit costs.
-- `public_ip_enabled` - (Optional) Whether to enable static public IP for the runner. Note there are account limits. To list limits, use the GitHub API: `GET /orgs/{org}/actions/hosted-runners/limits`. Defaults to false.
-- `image_version` - (Optional) The version of the runner image to deploy. This is only relevant for runners using custom images.
-
-## Timeouts
-
-The `timeouts` block allows you to specify timeouts for certain actions:
-
-- `delete` - (Defaults to 10 minutes) Used for waiting for the hosted runner deletion to complete.
-
-Example:
-
-{{ tffile "examples/resources/actions_hosted_runner/example_3.tf" }}
-
-## Attributes Reference
-
-In addition to the arguments above, the following attributes are exported:
-
-- `id` - The ID of the hosted runner.
-- `status` - Current status of the runner (e.g., "Ready", "Provisioning").
-- `platform` - Platform of the runner (e.g., "linux-x64", "win-x64").
-- `image` - In addition to the arguments above, the image block exports:
- - `size_gb` - The size of the image in gigabytes.
-- `machine_size_details` - Detailed specifications of the machine size:
- - `id` - Machine size identifier.
- - `cpu_cores` - Number of CPU cores.
- - `memory_gb` - Amount of memory in gigabytes.
- - `storage_gb` - Amount of storage in gigabytes.
-- `public_ips` - List of public IP ranges assigned to this runner (only if `public_ip_enabled` is true):
- - `enabled` - Whether this IP range is enabled.
- - `prefix` - IP address prefix.
- - `length` - Subnet length.
-- `last_active_on` - Timestamp (RFC3339) when the runner was last active.
-
-## Import
-
-Hosted runners can be imported using the runner ID:
-
-```shell
-terraform import github_actions_hosted_runner.example 123456
-```
+{{ .Description | trimspace }}
## Notes
- This resource is **organization-only** and cannot be used with individual accounts.
-- The `image` field cannot be changed after the runner is created. Changing it will force recreation of the runner.
-- The `size` field can be updated to scale the runner up or down as needed.
- Image IDs for GitHub-owned images are numeric strings (e.g., "2306" for Ubuntu Latest 24.04), not names like "ubuntu-latest".
- Deletion of hosted runners is asynchronous. The provider will poll for up to 10 minutes (configurable via timeouts) to confirm deletion.
- Runner creation and updates may take several minutes as GitHub provisions the infrastructure.
-- Static public IPs are subject to account limits. Check your organization's limits before enabling.
## Getting Available Images and Sizes
@@ -99,3 +33,47 @@ curl -H "Authorization: Bearer YOUR_TOKEN" \
-H "Accept: application/vnd.github+json" \
https://api.github.com/orgs/YOUR_ORG/actions/hosted-runners/machine-sizes
```
+
+{{ if .HasExamples -}}
+## Example Usage
+
+{{- range .ExampleFiles }}
+
+{{- $parts := split . "resource_" -}}
+{{- $lastItem := index $parts 1 }}
+{{- $fileNameParts := split $lastItem "." }}
+{{- $fileName := index $fileNameParts 0 }}
+
+### {{ $fileName | title }} Usage
+
+{{ tffile . }}
+{{- end }}
+{{- end }}
+
+{{ .SchemaMarkdown | trimspace }}
+{{- if or .HasImport .HasImportIDConfig .HasImportIdentityConfig }}
+
+## Import
+
+Import is supported using the following syntax:
+{{- end }}
+{{- if .HasImportIdentityConfig }}
+
+In Terraform v1.12.0 and later, the [`import` block](https://developer.hashicorp.com/terraform/language/import) can be used with the `identity` attribute, for example:
+
+{{tffile .ImportIdentityConfigFile }}
+
+{{ .IdentitySchemaMarkdown | trimspace }}
+{{- end }}
+{{- if .HasImportIDConfig }}
+
+In Terraform v1.5.0 and later, the [`import` block](https://developer.hashicorp.com/terraform/language/import) can be used with the `id` attribute, for example:
+
+{{tffile .ImportIDConfigFile }}
+{{- end }}
+{{- if .HasImport }}
+
+The [`terraform import` command](https://developer.hashicorp.com/terraform/cli/commands/import) can be used, for example:
+
+{{codefile "shell" .ImportFile }}
+{{- end }}