diff --git a/README.md b/README.md
index 1ca2e82e22..75af9acb78 100644
--- a/README.md
+++ b/README.md
@@ -145,7 +145,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 |
@@ -201,7 +201,7 @@ Join our discord community via [this invite link](https://discord.gg/bxgXW8jJGh)
| [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\_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(number)
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 |
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/lambdas/functions/control-plane/src/aws/runners.ts b/lambdas/functions/control-plane/src/aws/runners.ts
index d95dc99fa4..a45c1db3a2 100644
--- a/lambdas/functions/control-plane/src/aws/runners.ts
+++ b/lambdas/functions/control-plane/src/aws/runners.ts
@@ -207,6 +207,7 @@ async function processFleetResult(
'MaxSpotInstanceCountExceeded',
'MaxSpotFleetRequestCountExceeded',
'InsufficientInstanceCapacity',
+ 'InsufficientCapacityOnHost',
];
const failedCount = countScaleErrors(errors, scaleErrors);
diff --git a/modules/multi-runner/README.md b/modules/multi-runner/README.md
index 7920092afa..7a66b85d0b 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), [])
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), [])
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 |
| [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/variables.tf b/modules/multi-runner/variables.tf
index 0ca473ecf2..2262918a37 100644
--- a/modules/multi-runner/variables.tf
+++ b/modules/multi-runner/variables.tf
@@ -185,7 +185,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 |
@@ -214,7 +214,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 5522c5fb45..7e2017d8b5 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
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..893a62b098
--- /dev/null
+++ b/modules/runners/templates/install-runner-osx.sh
@@ -0,0 +1,60 @@
+# 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
+
+user_name="${RUNNER_USER:-ec2-user}"
+
+echo "Set file ownership of action runner"
+sudo chown -R "$user_name":"$user_name" /opt/actions-runner
+sudo chown -R "$user_name":"$user_name" /opt/hostedtoolcache
diff --git a/modules/runners/templates/start-runner-osx.sh b/modules/runners/templates/start-runner-osx.sh
new file mode 100644
index 0000000000..20541a2312
--- /dev/null
+++ b/modules/runners/templates/start-runner-osx.sh
@@ -0,0 +1,196 @@
+#!/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 <