diff --git a/README.md b/README.md
index d7b77ba3a5..fa697d8dcd 100644
--- a/README.md
+++ b/README.md
@@ -68,14 +68,14 @@ Join our discord community via [this invite link](https://discord.gg/bxgXW8jJGh)
| Name | Version |
|------|---------|
| [terraform](#requirement\_terraform) | >= 1.3.0 |
-| [aws](#requirement\_aws) | >= 6.21 |
+| [aws](#requirement\_aws) | >= 6.33 |
| [random](#requirement\_random) | ~> 3.0 |
## Providers
| Name | Version |
|------|---------|
-| [aws](#provider\_aws) | >= 6.21 |
+| [aws](#provider\_aws) | >= 6.33 |
| [random](#provider\_random) | ~> 3.0 |
## Modules
@@ -144,7 +144,7 @@ Join our discord community via [this invite link](https://discord.gg/bxgXW8jJGh)
| [instance\_profile\_path](#input\_instance\_profile\_path) | The path that will be added to the instance\_profile, if not set the environment name will be used. | `string` | `null` | no |
| [instance\_target\_capacity\_type](#input\_instance\_target\_capacity\_type) | Default lifecycle used for runner instances, can be either `spot` or `on-demand`. | `string` | `"spot"` | no |
| [instance\_termination\_watcher](#input\_instance\_termination\_watcher) | Configuration for the instance termination watcher. This feature is Beta, changes will not trigger a major release as long in beta.
`enable`: Enable or disable the spot termination watcher.
'features': Enable or disable features of the termination watcher.
`memory_size`: Memory size limit in MB of the lambda.
`s3_key`: S3 key for syncer lambda function. Required if using S3 bucket to specify lambdas.
`s3_object_version`: S3 object version for syncer lambda function. Useful if S3 versioning is enabled on source bucket.
`timeout`: Time out of the lambda in seconds.
`zip`: File location of the lambda zip file. |
object({
enable = optional(bool, false)
features = optional(object({
enable_spot_termination_handler = optional(bool, true)
enable_spot_termination_notification_watcher = optional(bool, true)
}), {})
memory_size = optional(number, null)
s3_key = optional(string, null)
s3_object_version = optional(string, null)
timeout = optional(number, null)
zip = optional(string, null)
}) | `{}` | no |
-| [instance\_types](#input\_instance\_types) | List of instance types for the action runner. Defaults are based on runner\_os (al2023 for linux and Windows Server Core for win). | `list(string)` | [| no | +| [instance\_types](#input\_instance\_types) | List of instance types for the action runner. Defaults are based on runner\_os (al2023 for linux, macOS Sequoia for osx, Windows Server Core for win). | `list(string)` |
"m5.large",
"c5.large"
]
[| no | | [job\_queue\_retention\_in\_seconds](#input\_job\_queue\_retention\_in\_seconds) | The number of seconds the job is held in the queue before it is purged. | `number` | `86400` | no | | [job\_retry](#input\_job\_retry) | Experimental! Can be removed / changed without trigger a major release.Configure job retries. The configuration enables job retries (for ephemeral runners). After creating the instances a message will be published to a job retry queue. The job retry check lambda is checking after a delay if the job is queued. If not the message will be published again on the scale-up (build queue). Using this feature can impact the rate limit of the GitHub app.
"m5.large",
"c5.large"
]
object({
enable = optional(bool, false)
delay_in_seconds = optional(number, 300)
delay_backoff = optional(number, 2)
lambda_memory_size = optional(number, 256)
lambda_timeout = optional(number, 30)
max_attempts = optional(number, 1)
}) | `{}` | no |
| [key\_name](#input\_key\_name) | Key pair name | `string` | `null` | no |
@@ -189,7 +189,7 @@ Join our discord community via [this invite link](https://discord.gg/bxgXW8jJGh)
| [runner\_binaries\_syncer\_lambda\_timeout](#input\_runner\_binaries\_syncer\_lambda\_timeout) | Time out of the binaries sync lambda in seconds. | `number` | `300` | no |
| [runner\_binaries\_syncer\_lambda\_zip](#input\_runner\_binaries\_syncer\_lambda\_zip) | File location of the binaries sync lambda zip file. | `string` | `null` | no |
| [runner\_boot\_time\_in\_minutes](#input\_runner\_boot\_time\_in\_minutes) | The minimum time for an EC2 runner to boot and register as a runner. | `number` | `5` | no |
-| [runner\_cpu\_options](#input\_runner\_cpu\_options) | The CPU options for the instance. See https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/launch_template#cpu-options for details. Note that not all instance types support CPU options, see https://docs.aws.amazon.com/AWSEC2/latest/UserGuide/instance-optimize-cpu.html#instance-cpu-options | object({
core_count = number
threads_per_core = number
}) | `null` | no |
+| [runner\_cpu\_options](#input\_runner\_cpu\_options) | The CPU options for the instance. See https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/launch_template#cpu-options for details. Note that not all instance types support CPU options, see https://docs.aws.amazon.com/AWSEC2/latest/UserGuide/instance-optimize-cpu.html#instance-cpu-options | object({
core_count = optional(number)
threads_per_core = optional(number)
amd_sev_snp = optional(string)
nested_virtualization = optional(string)
}) | `null` | no |
| [runner\_credit\_specification](#input\_runner\_credit\_specification) | The credit option for CPU usage of a T instance. Can be unset, "standard" or "unlimited". | `string` | `null` | no |
| [runner\_disable\_default\_labels](#input\_runner\_disable\_default\_labels) | Disable default labels for the runners (os, architecture and `self-hosted`). If enabled, the runner will only have the extra labels provided in `runner_extra_labels`. In case you on own start script is used, this configuration parameter needs to be parsed via SSM. | `bool` | `false` | no |
| [runner\_ec2\_tags](#input\_runner\_ec2\_tags) | Map of tags that will be added to the launch template instance tag specifications. | `map(string)` | `{}` | no |
@@ -199,10 +199,11 @@ Join our discord community via [this invite link](https://discord.gg/bxgXW8jJGh)
| [runner\_hook\_job\_completed](#input\_runner\_hook\_job\_completed) | Script to be ran in the runner environment at the end of every job | `string` | `""` | no |
| [runner\_hook\_job\_started](#input\_runner\_hook\_job\_started) | Script to be ran in the runner environment at the beginning of every job | `string` | `""` | no |
| [runner\_iam\_role\_managed\_policy\_arns](#input\_runner\_iam\_role\_managed\_policy\_arns) | Attach AWS or customer-managed IAM policies (by ARN) to the runner IAM role | `list(string)` | `[]` | no |
+| [runner\_license\_specifications](#input\_runner\_license\_specifications) | Optional EC2 License Manager license configuration ARNs for the runner launch template. Required for macOS dedicated-host runners when the host resource group uses a Mac dedicated host license configuration. See https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/launch_template#license_specification for details. | list(object({
license_configuration_arn = string
})) | `[]` | no |
| [runner\_log\_files](#input\_runner\_log\_files) | (optional) List of logfiles to send to CloudWatch, will only be used if `enable_cloudwatch_agent` is set to true. Object description: `log_group_name`: Name of the log group, `prefix_log_group`: If true, the log group name will be prefixed with `/github-self-hosted-runners/list(object({
log_group_name = string
prefix_log_group = bool
file_path = string
log_stream_name = string
log_class = optional(string, "STANDARD")
})) | `null` | no |
| [runner\_metadata\_options](#input\_runner\_metadata\_options) | Metadata options for the ec2 runner instances. By default, the module uses metadata tags for bootstrapping the runner, only disable `instance_metadata_tags` when using custom scripts for starting the runner. | `map(any)` | {
"http_endpoint": "enabled",
"http_put_response_hop_limit": 1,
"http_tokens": "required",
"instance_metadata_tags": "enabled"
} | no |
| [runner\_name\_prefix](#input\_runner\_name\_prefix) | The prefix used for the GitHub runner name. The prefix will be used in the default start script to prefix the instance name when register the runner in GitHub. The value is available via an EC2 tag 'ghr:runner\_name\_prefix'. | `string` | `""` | no |
-| [runner\_os](#input\_runner\_os) | The EC2 Operating System type to use for action runner instances (linux,windows). | `string` | `"linux"` | no |
+| [runner\_os](#input\_runner\_os) | The EC2 Operating System type to use for action runner instances (linux, osx, windows). | `string` | `"linux"` | no |
| [runner\_placement](#input\_runner\_placement) | The placement options for the instance. See https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/launch_template#placement for details. | object({
affinity = optional(string)
availability_zone = optional(string)
group_id = optional(string)
group_name = optional(string)
host_id = optional(string)
host_resource_group_arn = optional(string)
spread_domain = optional(string)
tenancy = optional(string)
partition_number = optional(number)
}) | `null` | no |
| [runner\_run\_as](#input\_runner\_run\_as) | Run the GitHub actions agent as user. | `string` | `"ec2-user"` | no |
| [runners\_ebs\_optimized](#input\_runners\_ebs\_optimized) | Enable EBS optimization for the runner instances. | `bool` | `false` | no |
@@ -225,6 +226,7 @@ Join our discord community via [this invite link](https://discord.gg/bxgXW8jJGh)
| [syncer\_lambda\_s3\_object\_version](#input\_syncer\_lambda\_s3\_object\_version) | S3 object version for syncer lambda function. Useful if S3 versioning is enabled on source bucket. | `string` | `null` | no |
| [tags](#input\_tags) | Map of tags that will be added to created resources. By default resources will be tagged with name and environment. | `map(string)` | `{}` | no |
| [tracing\_config](#input\_tracing\_config) | Configuration for lambda tracing. | object({
mode = optional(string, null)
capture_http_requests = optional(bool, false)
capture_error = optional(bool, false)
}) | `{}` | no |
+| [use\_dedicated\_host](#input\_use\_dedicated\_host) | Use a dedicated host for the runner instances. | `bool` | `false` | no |
| [user\_agent](#input\_user\_agent) | User agent used for API calls by lambda functions. | `string` | `"github-aws-runners"` | no |
| [userdata\_content](#input\_userdata\_content) | Alternative user-data content, replacing the templated one. By providing your own user\_data you have to take care of installing all required software, including the action runner and registering the runner. Be-aware configuration parameters in SSM as well as tags are treated as internals. Changes will not trigger a breaking release. | `string` | `null` | no |
| [userdata\_post\_install](#input\_userdata\_post\_install) | Script to be ran after the GitHub Actions runner is installed on the EC2 instances | `string` | `""` | no |
diff --git a/docs/configuration.md b/docs/configuration.md
index 1776e30c76..6c8f33aaf9 100644
--- a/docs/configuration.md
+++ b/docs/configuration.md
@@ -282,6 +282,10 @@ In case the setup does not work as intended, trace the events through this seque
## Experimental features
+### macOS Runners
+
+This feature is in early stage and should be considered experimental. The module supports macOS-based GitHub Actions self-hosted runners on AWS EC2 Mac instances (`mac1.metal`, `mac2.metal`, `mac2-m2.metal`). macOS runners require dedicated hosts due to Apple's licensing requirements and have longer boot times (6–20 minutes). Set `runner_os = "osx"` and `use_dedicated_host = true` to enable. See the full [macOS Runners documentation](mac-runners.md) for details.
+
### Termination watcher
This feature is in early stage and therefore disabled by default. To enable the watcher, set `instance_termination_watcher.enable = true`.
diff --git a/docs/examples/dedicated-mac-hosts.md b/docs/examples/dedicated-mac-hosts.md
new file mode 100644
index 0000000000..fd2caa4291
--- /dev/null
+++ b/docs/examples/dedicated-mac-hosts.md
@@ -0,0 +1 @@
+--8<-- "examples/dedicated-mac-hosts/README.md"
diff --git a/docs/examples/index.md b/docs/examples/index.md
index ac611575fc..f095e68ad9 100644
--- a/docs/examples/index.md
+++ b/docs/examples/index.md
@@ -9,4 +9,5 @@ Examples are located in the [examples](https://github.com/github-aws-runners/ter
- _[Prebuilt Images](prebuilt.md)_: Example usages of deploying runners with a custom prebuilt image.
- _[Windows](windows.md)_: Example usage of creating a runner using Windows as the OS.
- _[Termination watcher](termination-watcher.md)_: Example usages of termination watcher.
+- _[Dedicated Mac Hosts](dedicated-mac-hosts.md)_: Example usage of setting up dedicated hosts for macOS runners.
- _[Externally managed SSM secrets](external-managed-ssm-secrets.md)_: Example usage of externally managed SSM secrets for the GitHub App credentials.
diff --git a/docs/mac-runners.md b/docs/mac-runners.md
new file mode 100644
index 0000000000..a68dfdbadc
--- /dev/null
+++ b/docs/mac-runners.md
@@ -0,0 +1,190 @@
+# macOS Runners (Experimental)
+
+!!! warning
+ This feature is in early stage and should be considered experimental. macOS runners on AWS have unique constraints compared to Linux and Windows runners. Please review all sections below before deploying.
+
+## Overview
+
+The module supports provisioning macOS-based GitHub Actions self-hosted runners on AWS using [Amazon EC2 Mac instances](https://aws.amazon.com/ec2/instance-types/mac/). macOS runners use the `osx` value for the `runner_os` variable and require **dedicated hosts** due to Apple's macOS licensing requirements.
+
+Key differences from Linux/Windows runners:
+
+- **Dedicated hosts required.** EC2 Mac instances must run on dedicated hosts. Each dedicated host can run **only one Mac VM at a time** (1:1 ratio). The module uses `RunInstances` directly instead of the `CreateFleet` API when `use_dedicated_host` is enabled.
+- **Longer boot times.** macOS instances can take 6–20 minutes to launch, significantly longer than Linux (~1 min) or Windows (~5 min). The default `minimum_running_time_in_minutes` for `osx` is set to 20 minutes to prevent premature scale-down.
+- **~50 minute host recycle time.** After an EC2 Mac instance is terminated, AWS performs install cleanup and software upgrades on the dedicated host before it becomes available again. This process takes approximately **50 minutes**, during which the host cannot launch a new instance.
+- **ARM64 (Apple Silicon) and x64 (Intel) support.** Both `mac1.metal` (Intel), `mac2.metal` (M1), and `mac2-m2.metal` (M2) instance types are supported. Set `runner_architecture` accordingly (`x64` or `arm64`).
+- **Only ephemeral mode is recommended.** Due to the long host allocation time and dedicated host cost model, we recommend using ephemeral runners.
+
+## Scaling caveats
+
+Running macOS runners at scale introduces challenges that do not exist with Linux or Windows runners:
+
+1. **1:1 host-to-VM ratio.** Unlike Linux where many instances share underlying hardware, each Mac VM requires its own dedicated host. To run N concurrent macOS jobs, you need at least N dedicated hosts.
+2. **Host recycle delay.** After a Mac instance is terminated, the dedicated host enters a ~50 minute cleanup cycle (scrubbing, software updates). During this window the host is unavailable. For bursty workloads, you need additional hosts to absorb demand while others recycle.
+3. **Capacity planning.** Dedicated hosts must be allocated ahead of time and are limited by your AWS account quota. Reserve enough hosts for your maximum expected concurrent macOS jobs. After a job finishes and the Mac instance is terminated, add the job runtime plus approximately 50 minutes before that host can run another Mac instance. For example, a 30 minute job keeps its host unavailable for about 80 minutes total.
+
+## Prerequisites
+
+Before deploying macOS runners, you must set up dedicated host infrastructure. There are two approaches:
+
+### Option A: Single dedicated host
+
+The simplest setup — allocate a single dedicated host and reference it directly. This works for low-scale or testing scenarios, but you must update the Terraform configuration whenever you replace the host.
+
+1. **Dedicated Host** — Allocate an EC2 dedicated host for your Mac instance type in the target availability zone.
+
+### Option B: Host resource group (recommended for scale)
+
+A host resource group allows you to associate **multiple dedicated hosts within an availability zone** into a logical group. When launching a Mac instance, AWS randomly selects an available host from the group. This means you can add, release, or replace individual dedicated hosts **without changing Terraform state or module inputs** — you only reference the group ARN, not individual host ARNs.
+
+This approach requires three resources:
+
+1. **Dedicated Hosts** — Allocate one or more EC2 dedicated hosts for Mac instance types in your target availability zones.
+2. **Host Resource Group** — Create an AWS Resource Groups group of type `AWS::EC2::HostManagement` and add your dedicated hosts as members.
+3. **License Configuration** — Create an AWS License Manager license configuration for Mac dedicated hosts (counting type: `Socket`). Associate it with the macOS AMI and the host resource group. The license configuration ARN is passed to the module via the `license_specifications` input.
+
+The [dedicated-mac-hosts example](examples/dedicated-mac-hosts.md) provides a ready-to-use Terraform configuration for all three resources.
+
+## Configuration
+
+### Basic setup
+
+```hcl
+module "runners" {
+ source = "github-aws-runners/github-runners/aws"
+
+ # macOS-specific settings
+ runner_os = "osx"
+ runner_architecture = "arm64" # or "x64" for Intel Mac instances
+ instance_types = ["mac2.metal"]
+ instance_target_capacity_type = "on-demand"
+
+ # Dedicated host settings (required for macOS)
+ use_dedicated_host = true
+ placement = {
+ host_resource_group_arn = "map(object({
name = string
host_instance_type = string
hosts = list(object({
name = string
availability_zone = string
}))
})) | n/a | yes |
+
+## Outputs
+
+| Name | Description |
+|------|-------------|
+| [license\_specification\_arn](#output\_license\_specification\_arn) | ARN of the License Manager configuration used for Mac dedicated hosts. |
+| [resource\_group\_arns](#output\_resource\_group\_arns) | Map of resource group names to their ARNs. |
+
diff --git a/examples/dedicated-mac-hosts/main.tf b/examples/dedicated-mac-hosts/main.tf
new file mode 100644
index 0000000000..f0b1537859
--- /dev/null
+++ b/examples/dedicated-mac-hosts/main.tf
@@ -0,0 +1,105 @@
+locals {
+
+ environment = var.environment != null ? var.environment : "default"
+ aws_region = var.aws_region
+
+ # Flatten host_groups into a map of individual host definitions keyed by
+ # "groupKey-hostName" so we can create one aws_ec2_host per host.
+ mac_dedicated_hosts = merge([
+ for group_key, group in var.host_groups : {
+ for host in group.hosts :
+ "${group_key}-${host.name}" => {
+ instance_type = group.host_instance_type
+ availability_zone = host.availability_zone
+ group_name = group.name
+ host_name = host.name
+ }
+ }
+ ]...)
+}
+
+resource "aws_ec2_host" "mac_dedicated_host" {
+ for_each = local.mac_dedicated_hosts
+
+ instance_type = each.value.instance_type
+ availability_zone = each.value.availability_zone
+ auto_placement = "on"
+
+ tags = {
+ "Name" = each.value.host_name
+ "HostGroup" = each.value.group_name
+ }
+}
+
+resource "aws_resourcegroups_group" "mac_host_group" {
+ for_each = { for _, group in var.host_groups : group.name => group }
+
+ name = each.value.name
+
+ configuration {
+ type = "AWS::EC2::HostManagement"
+
+ parameters {
+ name = "any-host-based-license-configuration"
+ values = ["true"]
+ }
+
+ parameters {
+ name = "auto-allocate-host"
+ values = [
+ "false",
+ ]
+ }
+ parameters {
+ name = "auto-host-recovery"
+ values = [
+ "false",
+ ]
+ }
+ parameters {
+ name = "auto-release-host"
+ values = [
+ "false",
+ ]
+ }
+ }
+
+ configuration {
+ type = "AWS::ResourceGroups::Generic"
+ parameters {
+ name = "allowed-resource-types"
+ values = [
+ "AWS::EC2::Host",
+ ]
+ }
+
+ parameters {
+ name = "deletion-protection"
+ values = [
+ "UNLESS_EMPTY",
+ ]
+ }
+ }
+
+ tags = {
+ "Name" = each.value.name
+ }
+}
+
+resource "aws_resourcegroups_resource" "mac_host_membership" {
+ for_each = local.mac_dedicated_hosts
+
+ group_arn = aws_resourcegroups_group.mac_host_group[each.value.group_name].arn
+ resource_arn = aws_ec2_host.mac_dedicated_host[each.key].arn
+}
+
+
+resource "aws_licensemanager_license_configuration" "mac_dedicated_host_license_configuration" {
+ name = "mac-dedicated-host-license-configuration"
+ description = "Mac dedicated host license configuration"
+ license_counting_type = "Socket"
+
+ tags = {
+ "Name" = "mac-dedicated-host-license-configuration"
+ }
+}
diff --git a/examples/dedicated-mac-hosts/outputs.tf b/examples/dedicated-mac-hosts/outputs.tf
new file mode 100644
index 0000000000..4aa7dda086
--- /dev/null
+++ b/examples/dedicated-mac-hosts/outputs.tf
@@ -0,0 +1,12 @@
+output "resource_group_arns" {
+ description = "Map of resource group names to their ARNs."
+ value = {
+ for k, rg in aws_resourcegroups_group.mac_host_group :
+ rg.name => rg.arn
+ }
+}
+
+output "license_specification_arn" {
+ description = "ARN of the License Manager configuration used for Mac dedicated hosts."
+ value = aws_licensemanager_license_configuration.mac_dedicated_host_license_configuration.arn
+}
diff --git a/examples/dedicated-mac-hosts/providers.tf b/examples/dedicated-mac-hosts/providers.tf
new file mode 100644
index 0000000000..eca2fe96a7
--- /dev/null
+++ b/examples/dedicated-mac-hosts/providers.tf
@@ -0,0 +1,9 @@
+provider "aws" {
+ region = local.aws_region
+
+ default_tags {
+ tags = {
+ Example = local.environment
+ }
+ }
+}
diff --git a/examples/dedicated-mac-hosts/variables.tf b/examples/dedicated-mac-hosts/variables.tf
new file mode 100644
index 0000000000..3efed4af38
--- /dev/null
+++ b/examples/dedicated-mac-hosts/variables.tf
@@ -0,0 +1,23 @@
+variable "aws_region" {
+ description = "AWS region."
+ type = string
+}
+
+variable "environment" {
+ description = "Environment name, used as prefix."
+
+ type = string
+ default = null
+}
+
+variable "host_groups" {
+ description = "Map of host groups, each with a name, host instance type, and a list of hosts (name + AZ)."
+ type = map(object({
+ name = string
+ host_instance_type = string
+ hosts = list(object({
+ name = string
+ availability_zone = string
+ }))
+ }))
+}
diff --git a/examples/dedicated-mac-hosts/versions.tf b/examples/dedicated-mac-hosts/versions.tf
new file mode 100644
index 0000000000..af69406fbd
--- /dev/null
+++ b/examples/dedicated-mac-hosts/versions.tf
@@ -0,0 +1,10 @@
+terraform {
+ required_providers {
+ aws = {
+ source = "hashicorp/aws"
+ version = ">= 6.21"
+ }
+ }
+
+ required_version = ">= 1.3.0"
+}
diff --git a/examples/default/README.md b/examples/default/README.md
index 2eae797fd7..c75e37831f 100644
--- a/examples/default/README.md
+++ b/examples/default/README.md
@@ -34,7 +34,7 @@ terraform output -raw webhook_secret
| Name | Version |
|------|---------|
| [terraform](#requirement\_terraform) | >= 1.3.0 |
-| [aws](#requirement\_aws) | >= 6.21 |
+| [aws](#requirement\_aws) | >= 6.33 |
| [local](#requirement\_local) | ~> 2.0 |
| [random](#requirement\_random) | ~> 3.0 |
@@ -42,7 +42,7 @@ terraform output -raw webhook_secret
| Name | Version |
|------|---------|
-| [random](#provider\_random) | 3.7.2 |
+| [random](#provider\_random) | 3.8.1 |
## Modules
diff --git a/examples/ephemeral/README.md b/examples/ephemeral/README.md
index 04f2177d7e..e6bf4961b4 100644
--- a/examples/ephemeral/README.md
+++ b/examples/ephemeral/README.md
@@ -33,7 +33,7 @@ terraform output webhook_secret
| Name | Version |
|------|---------|
| [terraform](#requirement\_terraform) | >= 1.3.0 |
-| [aws](#requirement\_aws) | >= 6.21 |
+| [aws](#requirement\_aws) | >= 6.33 |
| [local](#requirement\_local) | ~> 2.0 |
| [random](#requirement\_random) | ~> 3.0 |
@@ -41,7 +41,7 @@ terraform output webhook_secret
| Name | Version |
|------|---------|
-| [random](#provider\_random) | 3.7.2 |
+| [random](#provider\_random) | 3.8.1 |
## Modules
diff --git a/examples/external-managed-ssm-secrets/README.md b/examples/external-managed-ssm-secrets/README.md
index 5a9a725dd3..af9c95a38c 100644
--- a/examples/external-managed-ssm-secrets/README.md
+++ b/examples/external-managed-ssm-secrets/README.md
@@ -80,7 +80,7 @@ terraform output -raw webhook_secret
| Name | Version |
|------|---------|
| [terraform](#requirement\_terraform) | >= 1.3.0 |
-| [aws](#requirement\_aws) | >= 6.21 |
+| [aws](#requirement\_aws) | >= 6.33 |
| [local](#requirement\_local) | ~> 2.0 |
| [random](#requirement\_random) | ~> 3.0 |
diff --git a/examples/multi-runner/README.md b/examples/multi-runner/README.md
index 8f14b48503..f0a737b790 100644
--- a/examples/multi-runner/README.md
+++ b/examples/multi-runner/README.md
@@ -53,7 +53,7 @@ terraform output -raw webhook_secret
| Name | Version |
|------|---------|
| [terraform](#requirement\_terraform) | >= 1.3.0 |
-| [aws](#requirement\_aws) | >= 6.21 |
+| [aws](#requirement\_aws) | >= 6.33 |
| [local](#requirement\_local) | ~> 2.0 |
| [random](#requirement\_random) | ~> 3.0 |
@@ -61,8 +61,8 @@ terraform output -raw webhook_secret
| Name | Version |
|------|---------|
-| [aws](#provider\_aws) | 6.22.1 |
-| [random](#provider\_random) | 3.7.2 |
+| [aws](#provider\_aws) | 6.35.1 |
+| [random](#provider\_random) | 3.8.1 |
## Modules
diff --git a/examples/multi-runner/variables.tf b/examples/multi-runner/variables.tf
index 009c3643db..bc490ae273 100644
--- a/examples/multi-runner/variables.tf
+++ b/examples/multi-runner/variables.tf
@@ -19,4 +19,4 @@ variable "aws_region" {
type = string
default = "eu-west-1"
-}
+}
\ No newline at end of file
diff --git a/examples/permissions-boundary/README.md b/examples/permissions-boundary/README.md
index a5b1857d62..523e10edda 100644
--- a/examples/permissions-boundary/README.md
+++ b/examples/permissions-boundary/README.md
@@ -35,7 +35,7 @@ terraform apply
| Name | Version |
|------|---------|
| [terraform](#requirement\_terraform) | >= 1.3.0 |
-| [aws](#requirement\_aws) | >= 6.21 |
+| [aws](#requirement\_aws) | >= 6.33 |
| [local](#requirement\_local) | ~> 2.0 |
| [random](#requirement\_random) | ~> 3.0 |
diff --git a/examples/prebuilt/README.md b/examples/prebuilt/README.md
index b24f47a01d..f90a47261e 100644
--- a/examples/prebuilt/README.md
+++ b/examples/prebuilt/README.md
@@ -78,7 +78,7 @@ terraform output webhook_secret
| Name | Version |
|------|---------|
| [terraform](#requirement\_terraform) | >= 1.3.0 |
-| [aws](#requirement\_aws) | >= 6.21 |
+| [aws](#requirement\_aws) | >= 6.33 |
| [local](#requirement\_local) | ~> 2.0 |
| [random](#requirement\_random) | ~> 3.0 |
@@ -86,8 +86,8 @@ terraform output webhook_secret
| Name | Version |
|------|---------|
-| [aws](#provider\_aws) | 6.22.1 |
-| [random](#provider\_random) | 3.7.2 |
+| [aws](#provider\_aws) | 6.35.1 |
+| [random](#provider\_random) | 3.8.1 |
## Modules
@@ -112,7 +112,7 @@ terraform output webhook_secret
| [aws\_region](#input\_aws\_region) | AWS region. | `string` | `"eu-west-1"` | no |
| [environment](#input\_environment) | Environment name, used as prefix. | `string` | `null` | no |
| [github\_app](#input\_github\_app) | GitHub for API usages. | object({
id = string
key_base64 = string
}) | n/a | yes |
-| [runner\_os](#input\_runner\_os) | The EC2 Operating System type to use for action runner instances (linux,windows). | `string` | `"linux"` | no |
+| [runner\_os](#input\_runner\_os) | The EC2 Operating System type to use for action runner instances (linux, osx, windows). | `string` | `"linux"` | no |
## Outputs
diff --git a/examples/prebuilt/variables.tf b/examples/prebuilt/variables.tf
index 643072a163..11670a5d2e 100644
--- a/examples/prebuilt/variables.tf
+++ b/examples/prebuilt/variables.tf
@@ -22,7 +22,7 @@ variable "aws_region" {
}
variable "runner_os" {
- description = "The EC2 Operating System type to use for action runner instances (linux,windows)."
+ description = "The EC2 Operating System type to use for action runner instances (linux, osx, windows)."
type = string
default = "linux"
diff --git a/lambdas/functions/control-plane/src/aws/runners.d.ts b/lambdas/functions/control-plane/src/aws/runners.d.ts
index f57652d491..01cd4c1459 100644
--- a/lambdas/functions/control-plane/src/aws/runners.d.ts
+++ b/lambdas/functions/control-plane/src/aws/runners.d.ts
@@ -70,4 +70,5 @@ export interface RunnerInputParameters {
tracingEnabled?: boolean;
onDemandFailoverOnError?: string[];
scaleErrors: string[];
+ useDedicatedHost?: boolean;
}
diff --git a/lambdas/functions/control-plane/src/aws/runners.test.ts b/lambdas/functions/control-plane/src/aws/runners.test.ts
index 2f1b05792e..e9f6c13969 100644
--- a/lambdas/functions/control-plane/src/aws/runners.test.ts
+++ b/lambdas/functions/control-plane/src/aws/runners.test.ts
@@ -10,6 +10,7 @@ import {
DescribeInstancesCommand,
type DescribeInstancesResult,
EC2Client,
+ RunInstancesCommand,
SpotAllocationStrategy,
TerminateInstancesCommand,
} from '@aws-sdk/client-ec2';
@@ -964,6 +965,7 @@ interface RunnerConfig {
onDemandFailoverOnError?: string[];
scaleErrors: string[];
source: LambdaRunnerSource;
+ useDedicatedHost?: boolean;
}
function createRunnerConfig(runnerConfig: RunnerConfig): RunnerInputParameters {
@@ -985,6 +987,7 @@ function createRunnerConfig(runnerConfig: RunnerConfig): RunnerInputParameters {
onDemandFailoverOnError: runnerConfig.onDemandFailoverOnError,
scaleErrors: runnerConfig.scaleErrors,
source: runnerConfig.source,
+ useDedicatedHost: runnerConfig.useDedicatedHost,
};
}
@@ -1073,3 +1076,217 @@ function expectedCreateFleetRequest(expectedValues: ExpectedFleetRequestValues):
return request;
}
+
+describe('create runner with useDedicatedHost', () => {
+ const dedicatedHostRunnerConfig: RunnerConfig = {
+ allocationStrategy: SpotAllocationStrategy.CAPACITY_OPTIMIZED,
+ capacityType: 'on-demand',
+ type: 'Org',
+ scaleErrors: [],
+ useDedicatedHost: true,
+ };
+
+ beforeEach(() => {
+ vi.clearAllMocks();
+ mockEC2Client.reset();
+ mockSSMClient.reset();
+
+ mockEC2Client.on(RunInstancesCommand).resolves({
+ Instances: [{ InstanceId: 'i-dedicated-1' }],
+ });
+ mockSSMClient.on(GetParameterCommand).resolves({});
+ });
+
+ it('uses RunInstances instead of CreateFleet when useDedicatedHost is true', async () => {
+ const result = await createRunner(createRunnerConfig(dedicatedHostRunnerConfig));
+
+ expect(result).toEqual(['i-dedicated-1']);
+ expect(mockEC2Client).toHaveReceivedCommand(RunInstancesCommand);
+ expect(mockEC2Client).not.toHaveReceivedCommand(CreateFleetCommand);
+ });
+
+ it('uses CreateFleet when useDedicatedHost is false', async () => {
+ mockEC2Client.on(CreateFleetCommand).resolves({ Instances: [{ InstanceIds: ['i-fleet-1'] }] });
+
+ const result = await createRunner(
+ createRunnerConfig({
+ ...dedicatedHostRunnerConfig,
+ useDedicatedHost: false,
+ }),
+ );
+
+ expect(result).toEqual(['i-fleet-1']);
+ expect(mockEC2Client).toHaveReceivedCommand(CreateFleetCommand);
+ expect(mockEC2Client).not.toHaveReceivedCommand(RunInstancesCommand);
+ });
+
+ it('uses CreateFleet when useDedicatedHost is undefined', async () => {
+ mockEC2Client.on(CreateFleetCommand).resolves({ Instances: [{ InstanceIds: ['i-fleet-1'] }] });
+
+ const result = await createRunner(
+ createRunnerConfig({
+ ...dedicatedHostRunnerConfig,
+ useDedicatedHost: undefined,
+ }),
+ );
+
+ expect(result).toEqual(['i-fleet-1']);
+ expect(mockEC2Client).toHaveReceivedCommand(CreateFleetCommand);
+ expect(mockEC2Client).not.toHaveReceivedCommand(RunInstancesCommand);
+ });
+
+ it('passes correct parameters to RunInstances', async () => {
+ await createRunner(createRunnerConfig(dedicatedHostRunnerConfig));
+
+ expect(mockEC2Client).toHaveReceivedCommandWith(RunInstancesCommand, {
+ LaunchTemplate: {
+ LaunchTemplateName: LAUNCH_TEMPLATE,
+ Version: '$Default',
+ },
+ InstanceType: 'm5.large',
+ MinCount: 1,
+ MaxCount: 1,
+ SubnetId: 'subnet-123',
+ TagSpecifications: [
+ {
+ ResourceType: 'instance',
+ Tags: [
+ { Key: 'ghr:Application', Value: 'github-action-runner' },
+ { Key: 'ghr:created_by', Value: 'scale-up-lambda' },
+ { Key: 'ghr:Type', Value: 'Org' },
+ { Key: 'ghr:Owner', Value: REPO_NAME },
+ ],
+ },
+ {
+ ResourceType: 'volume',
+ Tags: [
+ { Key: 'ghr:Application', Value: 'github-action-runner' },
+ { Key: 'ghr:created_by', Value: 'scale-up-lambda' },
+ { Key: 'ghr:Type', Value: 'Org' },
+ { Key: 'ghr:Owner', Value: REPO_NAME },
+ ],
+ },
+ ],
+ });
+ });
+
+ it('creates multiple instances via RunInstances', async () => {
+ mockEC2Client.on(RunInstancesCommand).resolves({
+ Instances: [{ InstanceId: 'i-dedicated-1' }, { InstanceId: 'i-dedicated-2' }],
+ });
+
+ const result = await createRunner({
+ ...createRunnerConfig(dedicatedHostRunnerConfig),
+ numberOfRunners: 2,
+ });
+
+ expect(result).toEqual(['i-dedicated-1', 'i-dedicated-2']);
+ expect(mockEC2Client).toHaveReceivedCommandWith(RunInstancesCommand, {
+ LaunchTemplate: {
+ LaunchTemplateName: LAUNCH_TEMPLATE,
+ Version: '$Default',
+ },
+ InstanceType: 'm5.large',
+ MinCount: 2,
+ MaxCount: 2,
+ SubnetId: 'subnet-123',
+ TagSpecifications: [
+ {
+ ResourceType: 'instance',
+ Tags: [
+ { Key: 'ghr:Application', Value: 'github-action-runner' },
+ { Key: 'ghr:created_by', Value: 'pool-lambda' },
+ { Key: 'ghr:Type', Value: 'Org' },
+ { Key: 'ghr:Owner', Value: REPO_NAME },
+ ],
+ },
+ {
+ ResourceType: 'volume',
+ Tags: [
+ { Key: 'ghr:Application', Value: 'github-action-runner' },
+ { Key: 'ghr:created_by', Value: 'pool-lambda' },
+ { Key: 'ghr:Type', Value: 'Org' },
+ { Key: 'ghr:Owner', Value: REPO_NAME },
+ ],
+ },
+ ],
+ });
+ });
+
+ it('throws error when spot is used with dedicated host', async () => {
+ await expect(
+ createRunner(
+ createRunnerConfig({
+ ...dedicatedHostRunnerConfig,
+ capacityType: 'spot',
+ }),
+ ),
+ ).rejects.toThrow('Spot instances are not supported with RunInstances');
+ expect(mockEC2Client).not.toHaveReceivedCommand(RunInstancesCommand);
+ });
+
+ it('throws error when RunInstances returns no instances', async () => {
+ mockEC2Client.on(RunInstancesCommand).resolves({ Instances: [] });
+
+ await expect(createRunner(createRunnerConfig(dedicatedHostRunnerConfig))).rejects.toThrow(
+ 'RunInstances returned no instances for dedicated host.',
+ );
+ });
+
+ it('throws error when RunInstances fails', async () => {
+ mockEC2Client.on(RunInstancesCommand).rejects(new Error('EC2 error'));
+
+ await expect(createRunner(createRunnerConfig(dedicatedHostRunnerConfig))).rejects.toThrow('EC2 error');
+ });
+
+ it('uses ami id override from ssm parameter', async () => {
+ const paramValue: GetParameterResult = {
+ Parameter: {
+ Value: 'ami-dedicated',
+ },
+ };
+ mockSSMClient.on(GetParameterCommand).resolves(paramValue);
+
+ await createRunner(
+ createRunnerConfig({
+ ...dedicatedHostRunnerConfig,
+ amiIdSsmParameterName: 'my-ami-id-param',
+ }),
+ );
+
+ expect(mockEC2Client).toHaveReceivedCommandWith(RunInstancesCommand, {
+ LaunchTemplate: {
+ LaunchTemplateName: LAUNCH_TEMPLATE,
+ Version: '$Default',
+ },
+ InstanceType: 'm5.large',
+ MinCount: 1,
+ MaxCount: 1,
+ SubnetId: 'subnet-123',
+ ImageId: 'ami-dedicated',
+ TagSpecifications: [
+ {
+ ResourceType: 'instance',
+ Tags: [
+ { Key: 'ghr:Application', Value: 'github-action-runner' },
+ { Key: 'ghr:created_by', Value: 'scale-up-lambda' },
+ { Key: 'ghr:Type', Value: 'Org' },
+ { Key: 'ghr:Owner', Value: REPO_NAME },
+ ],
+ },
+ {
+ ResourceType: 'volume',
+ Tags: [
+ { Key: 'ghr:Application', Value: 'github-action-runner' },
+ { Key: 'ghr:created_by', Value: 'scale-up-lambda' },
+ { Key: 'ghr:Type', Value: 'Org' },
+ { Key: 'ghr:Owner', Value: REPO_NAME },
+ ],
+ },
+ ],
+ });
+ expect(mockSSMClient).toHaveReceivedCommandWith(GetParameterCommand, {
+ Name: 'my-ami-id-param',
+ });
+ });
+});
diff --git a/lambdas/functions/control-plane/src/aws/runners.ts b/lambdas/functions/control-plane/src/aws/runners.ts
index 61edf99af6..0240d86a77 100644
--- a/lambdas/functions/control-plane/src/aws/runners.ts
+++ b/lambdas/functions/control-plane/src/aws/runners.ts
@@ -5,6 +5,7 @@ import {
DeleteTagsCommand,
DescribeInstancesCommand,
DescribeInstancesResult,
+ RunInstancesCommand,
EC2Client,
FleetLaunchTemplateOverridesRequest,
Tag,
@@ -160,6 +161,15 @@ export async function createRunner(runnerParameters: Runners.RunnerInputParamete
const ec2Client = getTracedAWSV3Client(new EC2Client({ region: process.env.AWS_REGION }));
const amiIdOverride = await getAmiIdOverride(runnerParameters);
+ // EC2 Fleet (CreateFleet) does not support launching instances onto dedicated hosts
+ // for instance types like mac*.metal. Use RunInstances directly instead.
+ if (runnerParameters.useDedicatedHost) {
+ logger.info('Using RunInstances for dedicated host placement (CreateFleet does not support dedicated hosts).');
+ const instances = await createInstancesWithRunInstances(runnerParameters, amiIdOverride, ec2Client);
+ logger.info(`Created instance(s) via RunInstances: ${instances.join(',')}`);
+ return instances;
+ }
+
const fleet: CreateFleetResult = await createInstances(runnerParameters, amiIdOverride, ec2Client);
const instances: string[] = await processFleetResult(fleet, runnerParameters);
@@ -297,6 +307,7 @@ async function createInstances(
],
Type: 'instant',
});
+ logger.debug('CreateFleet request payload.', { payload: createFleetCommand.input });
fleet = await ec2Client.send(createFleetCommand);
} catch (e) {
logger.warn('Create fleet request failed.', { error: e as Error });
@@ -305,6 +316,68 @@ async function createInstances(
return fleet;
}
+async function createInstancesWithRunInstances(
+ runnerParameters: Runners.RunnerInputParameters,
+ amiIdOverride: string | undefined,
+ ec2Client: EC2Client,
+): Promiseobject({
enable = optional(bool, false)
namespace = optional(string, "GitHub Runners")
metric = optional(object({
enable_github_app_rate_limit = optional(bool, true)
enable_job_retry = optional(bool, true)
enable_spot_termination_warning = optional(bool, true)
}), {})
}) | `{}` | no |
-| [multi\_runner\_config](#input\_multi\_runner\_config) | multi\_runner\_config = {