diff --git a/README.md b/README.md
index ca27685aa6..5a8f76a6a6 100644
--- a/README.md
+++ b/README.md
@@ -143,7 +143,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 |
@@ -196,11 +196,12 @@ 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) | The license specifications for the instance. 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) Replaces the module default cloudwatch log config. See https://docs.aws.amazon.com/AmazonCloudWatch/latest/monitoring/CloudWatch-Agent-Configuration-File-Details.html for details. | list(object({
log_group_name = string
prefix_log_group = bool
file_path = string
log_stream_name = string
})) | `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\_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(number)
spread_domain = optional(string)
tenancy = optional(string)
partition_number = optional(number)
}) | `null` | 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 |
| [runners\_lambda\_s3\_key](#input\_runners\_lambda\_s3\_key) | S3 key for runners lambda function. Required if using S3 bucket to specify lambdas. | `string` | `null` | no |
diff --git a/examples/dedicated-mac-hosts/README.md b/examples/dedicated-mac-hosts/README.md
new file mode 100644
index 0000000000..ad00c2533e
--- /dev/null
+++ b/examples/dedicated-mac-hosts/README.md
@@ -0,0 +1,42 @@
+
+## Requirements
+
+| Name | Version |
+|------|---------|
+| [terraform](#requirement\_terraform) | >= 1.3.0 |
+| [aws](#requirement\_aws) | >= 6.21 |
+
+## Providers
+
+| Name | Version |
+|------|---------|
+| [aws](#provider\_aws) | >= 6.21 |
+
+## Modules
+
+No modules.
+
+## Resources
+
+| Name | Type |
+|------|------|
+| [aws_ec2_host.mac_dedicated_host](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/ec2_host) | resource |
+| [aws_licensemanager_license_configuration.mac_dedicated_host_license_configuration](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/licensemanager_license_configuration) | resource |
+| [aws_resourcegroups_group.mac_host_group](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/resourcegroups_group) | resource |
+| [aws_resourcegroups_resource.mac_host_membership](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/resourcegroups_resource) | resource |
+
+## Inputs
+
+| Name | Description | Type | Default | Required |
+|------|-------------|------|---------|:--------:|
+| [aws\_region](#input\_aws\_region) | AWS region. | `string` | n/a | yes |
+| [environment](#input\_environment) | Environment name, used as prefix. | `string` | `null` | no |
+| [host\_groups](#input\_host\_groups) | Map of host groups, each with a name, host instance type, and a list of hosts (name + AZ). | 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..8d12844bf9
--- /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" = each.value.name
+ }
+}
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/prebuilt/README.md b/examples/prebuilt/README.md
index b24f47a01d..c2d66139d4 100644
--- a/examples/prebuilt/README.md
+++ b/examples/prebuilt/README.md
@@ -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/main.tf b/main.tf
index 0b40527d1a..f1365cd1f3 100644
--- a/main.tf
+++ b/main.tf
@@ -207,6 +207,7 @@ module "runners" {
credit_specification = var.runner_credit_specification
cpu_options = var.runner_cpu_options
placement = var.runner_placement
+ license_specifications = var.runner_license_specifications
enable_runner_binaries_syncer = var.enable_runner_binaries_syncer
lambda_s3_bucket = var.lambda_s3_bucket
diff --git a/modules/multi-runner/README.md b/modules/multi-runner/README.md
index 6d851cfa24..8cbb9d7d45 100644
--- a/modules/multi-runner/README.md
+++ b/modules/multi-runner/README.md
@@ -150,7 +150,7 @@ module "multi-runner" {
| [logging\_retention\_in\_days](#input\_logging\_retention\_in\_days) | Specifies the number of days you want to retain log events for the lambda log group. Possible values are: 0, 1, 3, 5, 7, 14, 30, 60, 90, 120, 150, 180, 365, 400, 545, 731, 1827, and 3653. | `number` | `180` | no |
| [matcher\_config\_parameter\_store\_tier](#input\_matcher\_config\_parameter\_store\_tier) | The tier of the parameter store for the matcher configuration. Valid values are `Standard`, and `Advanced`. | `string` | `"Standard"` | no |
| [metrics](#input\_metrics) | Configuration for metrics created by the module, by default metrics are disabled to avoid additional costs. When metrics are enable all metrics are created unless explicit configured otherwise. | object({
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 = {map(object({
runner_config = object({
runner_os = string
runner_architecture = string
runner_metadata_options = optional(map(any), {
instance_metadata_tags = "enabled"
http_endpoint = "enabled"
http_tokens = "required"
http_put_response_hop_limit = 1
})
ami = optional(object({
filter = optional(map(list(string)), { state = ["available"] })
owners = optional(list(string), ["amazon"])
id_ssm_parameter_arn = optional(string, null)
kms_key_arn = optional(string, null)
}), null)
create_service_linked_role_spot = optional(bool, false)
credit_specification = optional(string, null)
delay_webhook_event = optional(number, 30)
disable_runner_autoupdate = optional(bool, false)
ebs_optimized = optional(bool, false)
enable_ephemeral_runners = optional(bool, false)
enable_job_queued_check = optional(bool, null)
enable_on_demand_failover_for_errors = optional(list(string), [])
scale_errors = optional(list(string), [
"UnfulfillableCapacity",
"MaxSpotInstanceCountExceeded",
"TargetCapacityLimitExceededException",
"RequestLimitExceeded",
"ResourceLimitExceeded",
"MaxSpotInstanceCountExceeded",
"MaxSpotFleetRequestCountExceeded",
"InsufficientInstanceCapacity",
"InsufficientCapacityOnHost",
])
enable_organization_runners = optional(bool, false)
enable_runner_binaries_syncer = optional(bool, true)
enable_ssm_on_runners = optional(bool, false)
enable_userdata = optional(bool, true)
instance_allocation_strategy = optional(string, "lowest-price")
instance_max_spot_price = optional(string, null)
instance_target_capacity_type = optional(string, "spot")
instance_types = list(string)
job_queue_retention_in_seconds = optional(number, 86400)
minimum_running_time_in_minutes = optional(number, null)
pool_runner_owner = optional(string, null)
runner_as_root = optional(bool, false)
runner_boot_time_in_minutes = optional(number, 5)
runner_disable_default_labels = optional(bool, false)
runner_extra_labels = optional(list(string), [])
runner_group_name = optional(string, "Default")
runner_name_prefix = optional(string, "")
runner_run_as = optional(string, "ec2-user")
runners_maximum_count = number
runner_additional_security_group_ids = optional(list(string), [])
scale_down_schedule_expression = optional(string, "cron(*/5 * * * ? *)")
scale_up_reserved_concurrent_executions = optional(number, 1)
userdata_template = optional(string, null)
userdata_content = optional(string, null)
enable_jit_config = optional(bool, null)
enable_runner_detailed_monitoring = optional(bool, false)
enable_cloudwatch_agent = optional(bool, true)
cloudwatch_config = optional(string, null)
userdata_pre_install = optional(string, "")
userdata_post_install = optional(string, "")
runner_hook_job_started = optional(string, "")
runner_hook_job_completed = optional(string, "")
runner_ec2_tags = optional(map(string), {})
runner_iam_role_managed_policy_arns = optional(list(string), [])
vpc_id = optional(string, null)
subnet_ids = optional(list(string), null)
idle_config = optional(list(object({
cron = string
timeZone = string
idleCount = number
evictionStrategy = optional(string, "oldest_first")
})), [])
cpu_options = optional(object({
core_count = number
threads_per_core = number
}), null)
placement = optional(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(number)
spread_domain = optional(string)
tenancy = optional(string)
partition_number = optional(number)
}), null)
runner_log_files = optional(list(object({
log_group_name = string
prefix_log_group = bool
file_path = string
log_stream_name = string
})), null)
block_device_mappings = optional(list(object({
delete_on_termination = optional(bool, true)
device_name = optional(string, "/dev/xvda")
encrypted = optional(bool, true)
iops = optional(number)
kms_key_id = optional(string)
snapshot_id = optional(string)
throughput = optional(number)
volume_size = number
volume_type = optional(string, "gp3")
})), [{
volume_size = 30
}])
pool_config = optional(list(object({
schedule_expression = string
schedule_expression_timezone = optional(string)
size = number
})), [])
job_retry = optional(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)
}), {})
})
matcherConfig = object({
labelMatchers = list(list(string))
exactMatch = optional(bool, false)
priority = optional(number, 999)
})
redrive_build_queue = optional(object({
enabled = bool
maxReceiveCount = number
}), {
enabled = false
maxReceiveCount = null
})
})) | n/a | yes |
+| [multi\_runner\_config](#input\_multi\_runner\_config) | multi\_runner\_config = {map(object({
runner_config = object({
runner_os = string
runner_architecture = string
runner_metadata_options = optional(map(any), {
instance_metadata_tags = "enabled"
http_endpoint = "enabled"
http_tokens = "required"
http_put_response_hop_limit = 1
})
ami = optional(object({
filter = optional(map(list(string)), { state = ["available"] })
owners = optional(list(string), ["amazon"])
id_ssm_parameter_arn = optional(string, null)
kms_key_arn = optional(string, null)
}), null)
create_service_linked_role_spot = optional(bool, false)
credit_specification = optional(string, null)
delay_webhook_event = optional(number, 30)
disable_runner_autoupdate = optional(bool, false)
ebs_optimized = optional(bool, false)
enable_ephemeral_runners = optional(bool, false)
enable_job_queued_check = optional(bool, null)
enable_on_demand_failover_for_errors = optional(list(string), [])
scale_errors = optional(list(string), [
"UnfulfillableCapacity",
"MaxSpotInstanceCountExceeded",
"TargetCapacityLimitExceededException",
"RequestLimitExceeded",
"ResourceLimitExceeded",
"MaxSpotInstanceCountExceeded",
"MaxSpotFleetRequestCountExceeded",
"InsufficientInstanceCapacity",
"InsufficientCapacityOnHost",
])
enable_organization_runners = optional(bool, false)
enable_runner_binaries_syncer = optional(bool, true)
enable_ssm_on_runners = optional(bool, false)
enable_userdata = optional(bool, true)
instance_allocation_strategy = optional(string, "lowest-price")
instance_max_spot_price = optional(string, null)
instance_target_capacity_type = optional(string, "spot")
instance_types = list(string)
job_queue_retention_in_seconds = optional(number, 86400)
minimum_running_time_in_minutes = optional(number, null)
pool_runner_owner = optional(string, null)
runner_as_root = optional(bool, false)
runner_boot_time_in_minutes = optional(number, 5)
runner_disable_default_labels = optional(bool, false)
runner_extra_labels = optional(list(string), [])
runner_group_name = optional(string, "Default")
runner_name_prefix = optional(string, "")
runner_run_as = optional(string, "ec2-user")
runners_maximum_count = number
runner_additional_security_group_ids = optional(list(string), [])
scale_down_schedule_expression = optional(string, "cron(*/5 * * * ? *)")
scale_up_reserved_concurrent_executions = optional(number, 1)
userdata_template = optional(string, null)
userdata_content = optional(string, null)
enable_jit_config = optional(bool, null)
enable_runner_detailed_monitoring = optional(bool, false)
enable_cloudwatch_agent = optional(bool, true)
cloudwatch_config = optional(string, null)
userdata_pre_install = optional(string, "")
userdata_post_install = optional(string, "")
runner_hook_job_started = optional(string, "")
runner_hook_job_completed = optional(string, "")
runner_ec2_tags = optional(map(string), {})
runner_iam_role_managed_policy_arns = optional(list(string), [])
vpc_id = optional(string, null)
subnet_ids = optional(list(string), null)
idle_config = optional(list(object({
cron = string
timeZone = string
idleCount = number
evictionStrategy = optional(string, "oldest_first")
})), [])
cpu_options = optional(object({
core_count = number
threads_per_core = number
}), null)
placement = optional(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)
license_specifications = optional(list(object({
license_configuration_arn = string
})), [])
runner_log_files = optional(list(object({
log_group_name = string
prefix_log_group = bool
file_path = string
log_stream_name = string
})), null)
block_device_mappings = optional(list(object({
delete_on_termination = optional(bool, true)
device_name = optional(string, "/dev/xvda")
encrypted = optional(bool, true)
iops = optional(number)
kms_key_id = optional(string)
snapshot_id = optional(string)
throughput = optional(number)
volume_size = number
volume_type = optional(string, "gp3")
})), [{
volume_size = 30
}])
pool_config = optional(list(object({
schedule_expression = string
schedule_expression_timezone = optional(string)
size = number
})), [])
job_retry = optional(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)
}), {})
})
matcherConfig = object({
labelMatchers = list(list(string))
exactMatch = optional(bool, false)
priority = optional(number, 999)
})
redrive_build_queue = optional(object({
enabled = bool
maxReceiveCount = number
}), {
enabled = false
maxReceiveCount = null
})
})) | n/a | yes |
| [pool\_lambda\_reserved\_concurrent\_executions](#input\_pool\_lambda\_reserved\_concurrent\_executions) | Amount of reserved concurrent executions for the scale-up lambda function. A value of 0 disables lambda from being triggered and -1 removes any concurrency limitations. | `number` | `1` | no |
| [pool\_lambda\_timeout](#input\_pool\_lambda\_timeout) | Time out for the pool lambda in seconds. | `number` | `60` | no |
| [prefix](#input\_prefix) | The prefix used for naming resources | `string` | `"github-actions"` | no |
diff --git a/modules/multi-runner/runners.tf b/modules/multi-runner/runners.tf
index e8454b5b69..c89ee47618 100644
--- a/modules/multi-runner/runners.tf
+++ b/modules/multi-runner/runners.tf
@@ -56,6 +56,7 @@ module "runners" {
credit_specification = each.value.runner_config.credit_specification
cpu_options = each.value.runner_config.cpu_options
placement = each.value.runner_config.placement
+ license_specifications = each.value.runner_config.license_specifications
enable_runner_binaries_syncer = each.value.runner_config.enable_runner_binaries_syncer
lambda_s3_bucket = var.lambda_s3_bucket
diff --git a/modules/multi-runner/variables.tf b/modules/multi-runner/variables.tf
index 99c9a1ba00..e321302362 100644
--- a/modules/multi-runner/variables.tf
+++ b/modules/multi-runner/variables.tf
@@ -142,11 +142,14 @@ variable "multi_runner_config" {
group_id = optional(string)
group_name = optional(string)
host_id = optional(string)
- host_resource_group_arn = optional(number)
+ host_resource_group_arn = optional(string)
spread_domain = optional(string)
tenancy = optional(string)
partition_number = optional(number)
}), null)
+ license_specifications = optional(list(object({
+ license_configuration_arn = string
+ })), [])
runner_log_files = optional(list(object({
log_group_name = string
prefix_log_group = bool
@@ -196,7 +199,7 @@ variable "multi_runner_config" {
description = <object({
enable = optional(bool, false)
delay_in_seconds = optional(number, 300)
delay_backoff = optional(number, 2)
lambda_memory_size = optional(number, 256)
lambda_reserved_concurrent_executions = optional(number, 1)
lambda_timeout = optional(number, 30)
max_attempts = optional(number, 1)
}) | `{}` | no |
| [key\_name](#input\_key\_name) | Key pair name | `string` | `null` | no |
| [kms\_key\_arn](#input\_kms\_key\_arn) | Optional CMK Key ARN to be used for Parameter Store. | `string` | `null` | no |
@@ -185,6 +185,7 @@ yarn run dist
| [lambda\_timeout\_scale\_down](#input\_lambda\_timeout\_scale\_down) | Time out for the scale down lambda in seconds. | `number` | `60` | no |
| [lambda\_timeout\_scale\_up](#input\_lambda\_timeout\_scale\_up) | Time out for the scale up lambda in seconds. | `number` | `60` | no |
| [lambda\_zip](#input\_lambda\_zip) | File location of the lambda zip file. | `string` | `null` | no |
+| [license\_specifications](#input\_license\_specifications) | The license specifications for the instance. See https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/launch_template#license_specification for details. | list(object({
license_configuration_arn = string
})) | `[]` | no |
| [log\_level](#input\_log\_level) | Logging level for lambda logging. Valid values are 'silly', 'trace', 'debug', 'info', 'warn', 'error', 'fatal'. | `string` | `"info"` | no |
| [logging\_kms\_key\_id](#input\_logging\_kms\_key\_id) | Specifies the kms key id to encrypt the logs with | `string` | `null` | no |
| [logging\_retention\_in\_days](#input\_logging\_retention\_in\_days) | Specifies the number of days you want to retain log events for the lambda log group. Possible values are: 0, 1, 3, 5, 7, 14, 30, 60, 90, 120, 150, 180, 365, 400, 545, 731, 1827, and 3653. | `number` | `180` | no |
@@ -192,7 +193,7 @@ yarn run dist
| [metrics](#input\_metrics) | Configuration for metrics created by the module, by default metrics are disabled to avoid additional costs. When metrics are enable all metrics are created unless explicit configured otherwise. | object({
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 |
| [minimum\_running\_time\_in\_minutes](#input\_minimum\_running\_time\_in\_minutes) | The time an ec2 action runner should be running at minimum before terminated if non busy. If not set the default is calculated based on the OS. | `number` | `null` | no |
| [overrides](#input\_overrides) | This map provides the possibility to override some defaults. The following attributes are supported: `name_sg` overrides the `Name` tag for all security groups created by this module. `name_runner_agent_instance` overrides the `Name` tag for the ec2 instance defined in the auto launch configuration. `name_docker_machine_runners` overrides the `Name` tag spot instances created by the runner agent. | `map(string)` | {
"name_runner": "",
"name_sg": ""
} | no |
-| [placement](#input\_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(number)
spread_domain = optional(string)
tenancy = optional(string)
partition_number = optional(number)
}) | `null` | no |
+| [placement](#input\_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 |
| [pool\_config](#input\_pool\_config) | The configuration for updating the pool. The `pool_size` to adjust to by the events triggered by the `schedule_expression`. For example you can configure a cron expression for week days to adjust the pool to 10 and another expression for the weekend to adjust the pool to 1. Use `schedule_expression_timezone ` to override the schedule time zone (defaults to UTC). | list(object({
schedule_expression = string
schedule_expression_timezone = optional(string)
size = number
})) | `[]` | no |
| [pool\_lambda\_memory\_size](#input\_pool\_lambda\_memory\_size) | Lambda Memory size limit in MB for pool lambda | `number` | `512` | no |
| [pool\_lambda\_reserved\_concurrent\_executions](#input\_pool\_lambda\_reserved\_concurrent\_executions) | Amount of reserved concurrent executions for the scale-up lambda function. A value of 0 disables lambda from being triggered and -1 removes any concurrency limitations. | `number` | `1` | no |
@@ -214,7 +215,7 @@ yarn run dist
| [runner\_labels](#input\_runner\_labels) | All the labels for the runners (GitHub) including the default one's(e.g: self-hosted, linux, x64, label1, label2). Separate each label by a comma | `list(string)` | n/a | yes |
| [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
})) | `null` | 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\_run\_as](#input\_runner\_run\_as) | Run the GitHub actions agent as user. | `string` | `"ec2-user"` | no |
| [runners\_lambda\_s3\_key](#input\_runners\_lambda\_s3\_key) | S3 key for runners lambda function. Required if using S3 bucket to specify lambdas. | `string` | `null` | no |
| [runners\_lambda\_s3\_object\_version](#input\_runners\_lambda\_s3\_object\_version) | S3 object version for runners lambda function. Useful if S3 versioning is enabled on source bucket. | `string` | `null` | no |
diff --git a/modules/runners/main.tf b/modules/runners/main.tf
index 9a85a2f2c3..3cc259a732 100644
--- a/modules/runners/main.tf
+++ b/modules/runners/main.tf
@@ -20,21 +20,25 @@ locals {
default_ami = {
"windows" = { name = ["Windows_Server-2022-English-Full-ECS_Optimized-*"] }
"linux" = var.runner_architecture == "arm64" ? { name = ["al2023-ami-2023.*-kernel-6.*-arm64"] } : { name = ["al2023-ami-2023.*-kernel-6.*-x86_64"] }
+ "osx" = var.runner_architecture == "arm64" ? { name = ["amzn-ec2-macos-15.*-arm64"] } : { name = ["amzn-ec2-macos-15.*"] }
}
default_userdata_template = {
"windows" = "${path.module}/templates/user-data.ps1"
"linux" = "${path.module}/templates/user-data.sh"
+ "osx" = "${path.module}/templates/user-data-osx.sh"
}
userdata_install_runner = {
"windows" = "${path.module}/templates/install-runner.ps1"
"linux" = "${path.module}/templates/install-runner.sh"
+ "osx" = "${path.module}/templates/install-runner-osx.sh"
}
userdata_start_runner = {
"windows" = "${path.module}/templates/start-runner.ps1"
"linux" = "${path.module}/templates/start-runner.sh"
+ "osx" = "${path.module}/templates/start-runner-osx.sh"
}
# Handle AMI configuration
@@ -78,6 +82,13 @@ locals {
enable_cloudwatch_agent = var.enable_cloudwatch_agent
ssm_key_cloudwatch_agent_config = var.enable_cloudwatch_agent ? aws_ssm_parameter.cloudwatch_agent_config_runner[0].name : ""
}) : var.userdata_content) : ""
+
+ encoded_user_data = (
+ var.runner_os == "linux" ? base64gzip(local.user_data) :
+ var.runner_os == "windows" ? base64encode(local.user_data) :
+ var.runner_os == "osx" ? base64encode(local.user_data) :
+ null
+ )
}
data "aws_ami" "runner" {
@@ -186,6 +197,13 @@ resource "aws_launch_template" "runner" {
}
}
+ dynamic "license_specification" {
+ for_each = var.license_specifications
+ content {
+ license_configuration_arn = license_specification.value.license_configuration_arn
+ }
+ }
+
monitoring {
enabled = var.enable_runner_detailed_monitoring
}
@@ -267,7 +285,7 @@ resource "aws_launch_template" "runner" {
)
}
- user_data = var.runner_os == "windows" ? base64encode(local.user_data) : base64gzip(local.user_data)
+ user_data = local.encoded_user_data
tags = local.tags
diff --git a/modules/runners/scale-down-state-diagram.md b/modules/runners/scale-down-state-diagram.md
index b4f260eb2a..64e32bc141 100644
--- a/modules/runners/scale-down-state-diagram.md
+++ b/modules/runners/scale-down-state-diagram.md
@@ -117,7 +117,7 @@ stateDiagram-v2
note right of CheckMinimumTime
Minimum running time in minutes
- (Linux: 5min, Windows: 15min)
+ (Linux: 5min, Windows: 15min, OSX: 20min)
end note
note right of CheckBootTime
@@ -145,6 +145,6 @@ stateDiagram-v2
## Configuration Parameters
- **Cron Schedule**: `cron(*/5 * * * ? *)` (every 5 minutes)
-- **Minimum Runtime**: Linux 5min, Windows 15min
+- **Minimum Runtime**: Linux 5min, Windows 15min, OSX 20min
- **Boot Timeout**: Configurable via `runner_boot_time_in_minutes`
- **Idle Config**: Per-environment configuration for desired idle runners
diff --git a/modules/runners/scale-down.tf b/modules/runners/scale-down.tf
index d274e3d4f1..f3df5141b3 100644
--- a/modules/runners/scale-down.tf
+++ b/modules/runners/scale-down.tf
@@ -1,8 +1,11 @@
locals {
# Windows Runners can take their sweet time to do anything
+ # For an AWS vended AMI with a x86 Mac instance or a Apple silicon Mac instance,
+ # the launch time can range from approximately 6 minutes to 20 minutes.
min_runtime_defaults = {
"windows" = 15
"linux" = 5
+ "osx" = 20
}
}
resource "aws_lambda_function" "scale_down" {
diff --git a/modules/runners/templates/install-runner-osx.sh b/modules/runners/templates/install-runner-osx.sh
new file mode 100644
index 0000000000..7d212f6a01
--- /dev/null
+++ b/modules/runners/templates/install-runner-osx.sh
@@ -0,0 +1,58 @@
+# shellcheck shell=bash
+
+## install the runner (macOS)
+
+s3_location=${S3_LOCATION_RUNNER_DISTRIBUTION}
+architecture=${RUNNER_ARCHITECTURE}
+
+if [ -z "$RUNNER_TARBALL_URL" ] && [ -z "$s3_location" ]; then
+ echo "Neither RUNNER_TARBALL_URL or s3_location are set"
+ exit 1
+fi
+
+file_name="actions-runner.tar.gz"
+
+echo "Setting up GH Actions runner tool cache"
+mkdir -p /opt/hostedtoolcache
+
+echo "Creating actions-runner directory for the GH Action installation"
+sudo mkdir -p /opt/actions-runner
+cd /opt/actions-runner || exit 1
+
+if [[ -n "$RUNNER_TARBALL_URL" ]]; then
+ echo "Downloading the GH Action runner from $RUNNER_TARBALL_URL to $file_name"
+ curl -s -o "$file_name" -L "$RUNNER_TARBALL_URL"
+else
+ echo "Retrieving REGION from AWS API"
+ token="$(curl -s -f -X PUT "http://169.254.169.254/latest/api/token" \
+ -H "X-aws-ec2-metadata-token-ttl-seconds: 180")"
+
+ region="$(curl -s -f -H "X-aws-ec2-metadata-token: $token" \
+ http://169.254.169.254/latest/dynamic/instance-identity/document | jq -r .region)"
+ echo "Retrieved REGION from AWS API ($region)"
+
+ echo "Downloading the GH Action runner from s3 bucket $s3_location"
+ aws s3 cp "$s3_location" "$file_name" --region "$region" --no-progress
+fi
+
+echo "Un-tar action runner"
+tar xzf "./$file_name"
+echo "Delete tar file"
+rm -rf "$file_name"
+
+os_name=$(sw_vers -productName 2>/dev/null || echo "macOS")
+os_version=$(sw_vers -productVersion 2>/dev/null || echo "unknown")
+arch_name=$(uname -m)
+
+echo "OS: $os_name $os_version ($arch_name)"
+
+if ! command -v brew >/dev/null 2>&1; then
+ echo "Homebrew not found; skipping dependency installation via brew"
+else
+ echo "Homebrew detected; install any macOS-specific dependencies here if needed"
+ # Example: brew install jq awscli
+fi
+
+echo "Set file ownership of action runner"
+sudo chown -R "$user_name":staff /opt/actions-runner
+sudo chown -R "$user_name":staff /opt/hostedtoolcache
diff --git a/modules/runners/templates/install-runner.ps1 b/modules/runners/templates/install-runner.ps1
index 4219773ad8..a13f91a65b 100644
--- a/modules/runners/templates/install-runner.ps1
+++ b/modules/runners/templates/install-runner.ps1
@@ -11,4 +11,3 @@ Expand-Archive -Path actions-runner.zip -DestinationPath .
Write-Host "Delete zip file"
Remove-Item actions-runner.zip
-
diff --git a/modules/runners/templates/start-runner-osx.sh b/modules/runners/templates/start-runner-osx.sh
new file mode 100644
index 0000000000..c3ea08af20
--- /dev/null
+++ b/modules/runners/templates/start-runner-osx.sh
@@ -0,0 +1,194 @@
+#!/bin/bash
+
+set -euo pipefail
+
+# macOS variant of start-runner.sh
+
+tag_instance_with_runner_id() {
+ echo "Checking for .runner file to extract agent ID"
+
+ if [[ ! -f "/opt/actions-runner/.runner" ]]; then
+ echo "Warning: .runner file not found"
+ return 0
+ fi
+
+ echo "Found .runner file, extracting agent ID"
+ local agent_id
+ agent_id=$(jq -r '.agentId' /opt/actions-runner/.runner 2>/dev/null || echo "")
+
+ if [[ -z "$agent_id" || "$agent_id" == "null" ]]; then
+ echo "Warning: Could not extract agent ID from .runner file"
+ return 0
+ fi
+
+ echo "Tagging instance with GitHub runner agent ID: $agent_id"
+ if aws ec2 create-tags \
+ --region "$region" \
+ --resources "$instance_id" \
+ --tags Key=ghr:github_runner_id,Value="$agent_id"; then
+ echo "Successfully tagged instance with agent ID: $agent_id"
+ return 0
+ else
+ echo "Warning: Failed to tag instance with agent ID"
+ return 0
+ fi
+}
+
+cleanup() {
+ local exit_code="$1"
+
+ if [ "$exit_code" -ne 0 ]; then
+ echo "ERROR: runner-start-failed with exit code $exit_code"
+ fi
+
+ if [ "$agent_mode" = "ephemeral" ] || [ "$exit_code" -ne 0 ]; then
+ echo "Terminating instance"
+ aws ec2 terminate-instances \
+ --instance-ids "$instance_id" \
+ --region "$region" || true
+ fi
+}
+
+trap 'cleanup $?' EXIT
+
+echo "Retrieving TOKEN from AWS API"
+token=$(curl -f -X PUT "http://169.254.169.254/latest/api/token" \
+ -H "X-aws-ec2-metadata-token-ttl-seconds: 180" || true)
+if [ -z "$token" ]; then
+ retrycount=0
+ until [ -n "$token" ]; do
+ echo "Failed to retrieve token. Retrying in 5 seconds."
+ sleep 5
+ token=$(curl -f -X PUT "http://169.254.169.254/latest/api/token" \
+ -H "X-aws-ec2-metadata-token-ttl-seconds: 180" || true)
+ retrycount=$((retrycount + 1))
+ if [ $retrycount -gt 40 ]; then
+ break
+ fi
+ done
+fi
+
+region=$(curl -f -H "X-aws-ec2-metadata-token: $token" \
+ http://169.254.169.254/latest/dynamic/instance-identity/document | jq -r .region)
+echo "Retrieved REGION from AWS API ($region)"
+
+instance_id=$(curl -f -H "X-aws-ec2-metadata-token: $token" \
+ http://169.254.169.254/latest/meta-data/instance-id)
+echo "Retrieved INSTANCE_ID from AWS API ($instance_id)"
+
+availability_zone=$(curl -f -H "X-aws-ec2-metadata-token: $token" \
+ http://169.254.169.254/latest/meta-data/placement/availability-zone)
+
+environment=$(curl -f -H "X-aws-ec2-metadata-token: $token" \
+ http://169.254.169.254/latest/meta-data/tags/instance/ghr:environment || echo "")
+ssm_config_path=$(curl -f -H "X-aws-ec2-metadata-token: $token" \
+ http://169.254.169.254/latest/meta-data/tags/instance/ghr:ssm_config_path || echo "")
+runner_name_prefix=$(curl -f -H "X-aws-ec2-metadata-token: $token" \
+ http://169.254.169.254/latest/meta-data/tags/instance/ghr:runner_name_prefix || echo "")
+
+echo "Retrieved ghr:environment tag - ($environment)"
+echo "Retrieved ghr:ssm_config_path tag - ($ssm_config_path)"
+echo "Retrieved ghr:runner_name_prefix tag - ($runner_name_prefix)"
+
+parameters=$(aws ssm get-parameters-by-path \
+ --path "$ssm_config_path" \
+ --region "$region" \
+ --query "Parameters[*].{Name:Name,Value:Value}")
+echo "Retrieved parameters from AWS SSM ($parameters)"
+
+run_as=$(echo "$parameters" | jq -r '.[] | select(.Name == "'$ssm_config_path'/run_as") | .Value')
+echo "Retrieved /$ssm_config_path/run_as parameter - ($run_as)"
+
+agent_mode=$(echo "$parameters" | jq -r '.[] | select(.Name == "'$ssm_config_path'/agent_mode") | .Value')
+echo "Retrieved /$ssm_config_path/agent_mode parameter - ($agent_mode)"
+
+disable_default_labels=$(echo "$parameters" | jq -r '.[] | select(.Name == "'$ssm_config_path'/disable_default_labels") | .Value')
+echo "Retrieved /$ssm_config_path/disable_default_labels parameter - ($disable_default_labels)"
+
+enable_jit_config=$(echo "$parameters" | jq -r '.[] | select(.Name == "'$ssm_config_path'/enable_jit_config") | .Value')
+echo "Retrieved /$ssm_config_path/enable_jit_config parameter - ($enable_jit_config)"
+
+token_path=$(echo "$parameters" | jq -r '.[] | select(.Name == "'$ssm_config_path'/token_path") | .Value')
+echo "Retrieved /$ssm_config_path/token_path parameter - ($token_path)"
+
+echo "Get GH Runner config from AWS SSM"
+config=$(aws ssm get-parameter \
+ --name "$token_path"/"$instance_id" \
+ --with-decryption \
+ --region "$region" | jq -r ".Parameter | .Value")
+
+while [[ -z "$config" ]]; do
+ echo "Waiting for GH Runner config to become available in AWS SSM"
+ sleep 1
+ config=$(aws ssm get-parameter \
+ --name "$token_path"/"$instance_id" \
+ --with-decryption \
+ --region "$region" | jq -r ".Parameter | .Value")
+done
+
+echo "Delete GH Runner token from AWS SSM"
+aws ssm delete-parameter --name "$token_path"/"$instance_id" --region "$region"
+
+if [ -z "$run_as" ]; then
+ echo "No user specified, using default ec2-user account"
+ run_as="ec2-user"
+fi
+
+if [[ "$run_as" == "root" ]]; then
+ echo "run_as is set to root - export RUNNER_ALLOW_RUNASROOT=1"
+ export RUNNER_ALLOW_RUNASROOT=1
+fi
+
+sudo chown -R "$run_as" /opt/actions-runner
+
+info_arch=$(uname -m)
+info_os=$(sw_vers -productName 2>/dev/null || echo "macOS")
+info_ver=$(sw_vers -productVersion 2>/dev/null || echo "unknown")
+
+tee /opt/actions-runner/.setup_info <