diff --git a/README.md b/README.md
index 80243b35ca..a597511a03 100644
--- a/README.md
+++ b/README.md
@@ -149,7 +149,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 linit 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 |
@@ -203,7 +203,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\_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/prebuilt/README.md b/examples/prebuilt/README.md
index 2969ef8698..98373ccaa7 100644
--- a/examples/prebuilt/README.md
+++ b/examples/prebuilt/README.md
@@ -107,7 +107,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 6779dd39d2..433371059c 100644
--- a/lambdas/functions/control-plane/src/aws/runners.ts
+++ b/lambdas/functions/control-plane/src/aws/runners.ts
@@ -186,6 +186,7 @@ async function processFleetResult(
'MaxSpotInstanceCountExceeded',
'MaxSpotFleetRequestCountExceeded',
'InsufficientInstanceCapacity',
+ 'InsufficientCapacityOnHost',
];
if (
diff --git a/modules/multi-runner/README.md b/modules/multi-runner/README.md
index 90ae802242..1d59c00ef3 100644
--- a/modules/multi-runner/README.md
+++ b/modules/multi-runner/README.md
@@ -148,7 +148,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) # Defaults to null, in which case the module falls back to individual AMI variables (deprecated)
# Deprecated: Use ami object instead
ami_filter = optional(map(list(string)), { state = ["available"] })
ami_owners = optional(list(string), ["amazon"])
ami_id_ssm_parameter_name = optional(string, null)
ami_kms_key_arn = optional(string, "")
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)
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) # Defaults to null, in which case the module falls back to individual AMI variables (deprecated)
# Deprecated: Use ami object instead
ami_filter = optional(map(list(string)), { state = ["available"] })
ami_owners = optional(list(string), ["amazon"])
ami_id_ssm_parameter_name = optional(string, null)
ami_kms_key_arn = optional(string, "")
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)
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 0cf8607c09..953385b7ad 100644
--- a/modules/multi-runner/variables.tf
+++ b/modules/multi-runner/variables.tf
@@ -179,7 +179,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 |
@@ -215,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 68365f2280..1a2b889db8 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 from either the new object or old variables
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 <