- Architecture Overview and Infrastructure Components
- ECR Runner Image Repository
- Self-hosted GitHub Runner(s) Registration Token
- Hosting the Runner Docker Artifacts on CodeCommit
- Build/Push Runner Image using CodeBuild
- Scalable ECS Cluster
- EventBus and Schema Discover for Webhook Events
- ECS Runner Task Definition
- Lambda Function URL
- GitHub Webhook
- EventBridge Rule
- Testing the Final Infrastructure
An EventBridge rule will be required to filter for queued workflow jobs, and “react” by invoking an ECS task (target).
Some points to consider when setting up the rule:
- source event fields to include in rule’s pattern filter
- the Input path and Template configuration for the Input Transformer ContainerOverrideโfor example, source event values to pass to the ECS container as environment variables
- runner naming convention which ensures that a workflow job is assigned to a unique ephemeral runner
Terraform will be used to create a rule based on a sample workflow job event.
๐ | The example Input Transformer ContainerOverride associated with the rule only accommodates for GitHub workflow jobs with a single label assignment. In cases where the transformation includes multiple labels, consider using a Step Function State Machine. |
Before continuing, the following resources must already exist:
GitHub Organisation | foo-organisation |
Runner registration secret | arn:aws:secretsmanager:us-east-1:xxxxxxxxxxxx:secret:github-actions-runner-registration-token |
EventBus | github-actions-event-bus |
ECS Cluster | GitHub-Actions-Runners |
Runner ECS Task | github-actions-self-hosted-runner-debian-task |
Workflow Job Event Schema
The schema/structure of the workflow job event is stored in the EventBridge “Discovered schema registry“, with a schema name of “github.com@Workflow_job“.


Event Rule
Sample Event
Expand the content below to view the sample workflow_job event with a status of queued.
Sample workflow_job event with a status of queued
{
"version": "0",
"id": "fsdfsdfsdf-sdfs-fsdfs-dfsdf-sfdsfsdfsdfsdf",
"detail-type": "workflow_job",
"source": "github.com",
"account": "00000000000",
"time": "2021-01-27T01:42:33Z",
"region": "us-east-1",
"resources": [],
"detail": {
"action": "queued",
"workflow_job": {
"id": 77777777777,
"run_id": 6666666666,
"workflow_name": "CI",
"head_branch": "main",
"run_url": "https://api.github.com/repos/foo-organisation/foo-repository/actions/runs/6666666666",
"run_attempt": 1,
"node_id": "MU_plDAIHFKJKJFKDJFF9999999",
"head_sha": "8993uhgf49rg4g4g4gtg4tg4g4tg49tgh49gh94gg",
"url": "https://api.github.com/repos/foo-organisation/foo-repository/actions/jobs/77777777777",
"html_url": "https://github.com/foo-organisation/foo-repository/actions/runs/6666666666/job/77777777777",
"status": "queued",
"conclusion": null,
"created_at": "2021-01-27T01:42:30Z",
"started_at": "2021-01-27T01:42:30Z",
"completed_at": null,
"name": "build",
"steps": [],
"check_run_url": "https://api.github.com/repos/foo-organisation/foo-repository/check-runs/77777777777",
"labels": [
"debian-amd64_foo-organisation_foo-repository_6666666666_1"
],
"runner_id": null,
"runner_name": null,
"runner_group_id": null,
"runner_group_name": null
},
"repository": {
"id": 121212121,
"node_id": "I_KJHFHJDHFDK",
"name": "foo-repository",
"full_name": "foo-organisation/foo-repository",
"private": true,
"owner": {
"login": "foo-organisation",
"id": 123456789,
"node_id": "A_JHJFKDHFH",
"avatar_url": "https://avatars.githubusercontent.com/u/123456789?v=4",
"gravatar_id": "",
"url": "https://api.github.com/users/foo-organisation",
"html_url": "https://github.com/foo-organisation",
"followers_url": "https://api.github.com/users/foo-organisation/followers",
"following_url": "https://api.github.com/users/foo-organisation/following{/other_user}",
"gists_url": "https://api.github.com/users/foo-organisation/gists{/gist_id}",
"starred_url": "https://api.github.com/users/foo-organisation/starred{/owner}{/repo}",
"subscriptions_url": "https://api.github.com/users/foo-organisation/subscriptions",
"organizations_url": "https://api.github.com/users/foo-organisation/orgs",
"repos_url": "https://api.github.com/users/foo-organisation/repos",
"events_url": "https://api.github.com/users/foo-organisation/events{/privacy}",
"received_events_url": "https://api.github.com/users/foo-organisation/received_events",
"type": "Organization",
"site_admin": false
},
"html_url": "https://github.com/foo-organisation/foo-repository",
"description": null,
"fork": false,
"url": "https://api.github.com/repos/foo-organisation/foo-repository",
"forks_url": "https://api.github.com/repos/foo-organisation/foo-repository/forks",
"keys_url": "https://api.github.com/repos/foo-organisation/foo-repository/keys{/key_id}",
"collaborators_url": "https://api.github.com/repos/foo-organisation/foo-repository/collaborators{/collaborator}",
"teams_url": "https://api.github.com/repos/foo-organisation/foo-repository/teams",
"hooks_url": "https://api.github.com/repos/foo-organisation/foo-repository/hooks",
"issue_events_url": "https://api.github.com/repos/foo-organisation/foo-repository/issues/events{/number}",
"events_url": "https://api.github.com/repos/foo-organisation/foo-repository/events",
"assignees_url": "https://api.github.com/repos/foo-organisation/foo-repository/assignees{/user}",
"branches_url": "https://api.github.com/repos/foo-organisation/foo-repository/branches{/branch}",
"tags_url": "https://api.github.com/repos/foo-organisation/foo-repository/tags",
"blobs_url": "https://api.github.com/repos/foo-organisation/foo-repository/git/blobs{/sha}",
"git_tags_url": "https://api.github.com/repos/foo-organisation/foo-repository/git/tags{/sha}",
"git_refs_url": "https://api.github.com/repos/foo-organisation/foo-repository/git/refs{/sha}",
"trees_url": "https://api.github.com/repos/foo-organisation/foo-repository/git/trees{/sha}",
"statuses_url": "https://api.github.com/repos/foo-organisation/foo-repository/statuses/{sha}",
"languages_url": "https://api.github.com/repos/foo-organisation/foo-repository/languages",
"stargazers_url": "https://api.github.com/repos/foo-organisation/foo-repository/stargazers",
"contributors_url": "https://api.github.com/repos/foo-organisation/foo-repository/contributors",
"subscribers_url": "https://api.github.com/repos/foo-organisation/foo-repository/subscribers",
"subscription_url": "https://api.github.com/repos/foo-organisation/foo-repository/subscription",
"commits_url": "https://api.github.com/repos/foo-organisation/foo-repository/commits{/sha}",
"git_commits_url": "https://api.github.com/repos/foo-organisation/foo-repository/git/commits{/sha}",
"comments_url": "https://api.github.com/repos/foo-organisation/foo-repository/comments{/number}",
"issue_comment_url": "https://api.github.com/repos/foo-organisation/foo-repository/issues/comments{/number}",
"contents_url": "https://api.github.com/repos/foo-organisation/foo-repository/contents/{+path}",
"compare_url": "https://api.github.com/repos/foo-organisation/foo-repository/compare/{base}...{head}",
"merges_url": "https://api.github.com/repos/foo-organisation/foo-repository/merges",
"archive_url": "https://api.github.com/repos/foo-organisation/foo-repository/{archive_format}{/ref}",
"downloads_url": "https://api.github.com/repos/foo-organisation/foo-repository/downloads",
"issues_url": "https://api.github.com/repos/foo-organisation/foo-repository/issues{/number}",
"pulls_url": "https://api.github.com/repos/foo-organisation/foo-repository/pulls{/number}",
"milestones_url": "https://api.github.com/repos/foo-organisation/foo-repository/milestones{/number}",
"notifications_url": "https://api.github.com/repos/foo-organisation/foo-repository/notifications{?since,all,participating}",
"labels_url": "https://api.github.com/repos/foo-organisation/foo-repository/labels{/name}",
"releases_url": "https://api.github.com/repos/foo-organisation/foo-repository/releases{/id}",
"deployments_url": "https://api.github.com/repos/foo-organisation/foo-repository/deployments",
"created_at": "2021-09-23T09:27:03Z",
"updated_at": "2021-09-23T09:27:04Z",
"pushed_at": "2021-01-26T10:35:28Z",
"git_url": "git://github.com/foo-organisation/foo-repository.git",
"ssh_url": "git@github.com:foo-organisation/foo-repository.git",
"clone_url": "https://github.com/foo-organisation/foo-repository.git",
"svn_url": "https://github.com/foo-organisation/foo-repository",
"homepage": null,
"size": 5,
"stargazers_count": 0,
"watchers_count": 0,
"language": null,
"has_issues": true,
"has_projects": true,
"has_downloads": true,
"has_wiki": false,
"has_pages": false,
"has_discussions": false,
"forks_count": 0,
"mirror_url": null,
"archived": false,
"disabled": false,
"open_issues_count": 0,
"license": null,
"allow_forking": false,
"is_template": false,
"web_commit_signoff_required": false,
"topics": [],
"visibility": "private",
"forks": 0,
"open_issues": 0,
"watchers": 0,
"default_branch": "main"
},
"organization": {
"login": "foo-organisation",
"id": 123456789,
"node_id": "A_JHJFKDHFH",
"url": "https://api.github.com/orgs/foo-organisation",
"repos_url": "https://api.github.com/orgs/foo-organisation/repos",
"events_url": "https://api.github.com/orgs/foo-organisation/events",
"hooks_url": "https://api.github.com/orgs/foo-organisation/hooks",
"issues_url": "https://api.github.com/orgs/foo-organisation/issues",
"members_url": "https://api.github.com/orgs/foo-organisation/members{/member}",
"public_members_url": "https://api.github.com/orgs/foo-organisation/public_members{/member}",
"avatar_url": "https://avatars.githubusercontent.com/u/123456789?v=4",
"description": null
},
"sender": {
"login": "some-github-user",
"id": 88888888,
"node_id": "JKHKJFDKJFKDHFDKJHFg",
"avatar_url": "https://avatars.githubusercontent.com/u/88888888?v=4",
"gravatar_id": "",
"url": "https://api.github.com/users/some-github-user",
"html_url": "https://github.com/some-github-user",
"followers_url": "https://api.github.com/users/some-github-user/followers",
"following_url": "https://api.github.com/users/some-github-user/following{/other_user}",
"gists_url": "https://api.github.com/users/some-github-user/gists{/gist_id}",
"starred_url": "https://api.github.com/users/some-github-user/starred{/owner}{/repo}",
"subscriptions_url": "https://api.github.com/users/some-github-user/subscriptions",
"organizations_url": "https://api.github.com/users/some-github-user/orgs",
"repos_url": "https://api.github.com/users/some-github-user/repos",
"events_url": "https://api.github.com/users/some-github-user/events{/privacy}",
"received_events_url": "https://api.github.com/users/some-github-user/received_events",
"type": "User",
"site_admin": false
}
}
}
The event includes the following JSON key:value
pairs:
"source": "github.com" Identifies the source/service that generated the event |
"detail-type": "workflow_job" The Github webhook event that triggered the hook. In this case, workflow_job |
"detail.workflow_job.status": "queued" Status of the workflow job |
"detail.organization.login": "foo-organization" Github organization name |
"detail.workflow_job.labels": "debian-amd64_123456789_121212121_6666666666 _1" Runner label, as assigned to the “ runs-on: ” statement for the workflow job. The assumption is that workflow jobs use a single label, with a prefix of debian-amd64 , and combination of GitHub context values, to uniquely identify the ephemeral runner, i.e.,runs-on: "debian-amd64_${{ |
The following event pattern can be used to filter for events matching the above criteria:
{
"detail": {
"organization": {
"login": ["foo-organisation"]
},
"workflow_job": {
"labels": [{
"prefix": "debian-amd64"
}],
"status": ["queued"]
}
},
"detail-type": ["workflow_job"],
"source": ["github.com"]
}
The Terraform source for deploying the rule is included in the next section.
Deploy EventBridge Rule using Terraform
Create the Terraform files below, placing them in a common directory. Change names of variables to match your account/region, etc.
variables.tf
variable "aws_region" {
description = "AWS region"
type = string
default = "us-east-1"
}
variable "aws_account" {
description = "AWS account ID"
type = string
default = "xxxxxxxxxxxx"
}
variable "aws_profile" {
description = "AWS profile"
type = string
default = "my-aws-account-creds"
}
variable "cluster_name" {
type = string
description = "Target cluster where ECS runner task will execute"
default = "GitHub-Actions-Runners"
}
variable "event_bus_name" {
description = "Eventbridge bus name where github events are received"
type = string
default = "github-actions-event-bus"
}
data.tf
data "aws_cloudwatch_event_bus" "this" {
name = var.event_bus_name
}
iam.tf
# IAM role trust relationship
resource "aws_iam_role" "this" {
name = "event_rule_role"
description = "IAM policy for event"
assume_role_policy = <<EOF
{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Principal": {
"Service": "events.amazonaws.com"
},
"Action": "sts:AssumeRole"
}
]
}
EOF
}
# IAM policy allowing eventbridge rule to trigger ecs task
resource "aws_iam_policy" "this" {
name = "event_rule_trust_policy"
description = "IAM policy for event target"
policy = jsonencode({
"Version" : "2012-10-17",
"Statement" : [
{
"Effect" : "Allow",
"Action" : [
"ecs:RunTask"
],
"Resource" : [
"arn:aws:ecs:us-east-1:xxxxxxxxxxxx:task-definition/${local.target_ecs_task_name}:*",
"arn:aws:ecs:us-east-1:xxxxxxxxxxxx:task-definition/${local.target_ecs_task_name}"
],
"Condition" : {
"ArnLike" : {
"ecs:cluster" : "arn:aws:ecs:us-east-1:xxxxxxxxxxxx:cluster/${var.cluster_name}"
}
}
},
{
"Effect" : "Allow",
"Action" : "iam:PassRole",
"Resource" : [
"*"
],
"Condition" : {
"StringLike" : {
"iam:PassedToService" : "ecs-tasks.amazonaws.com"
}
}
}
]
})
}
# Attach policy to IAM role
resource "aws_iam_role_policy_attachment" "this" {
role = aws_iam_role.this.name
policy_arn = aws_iam_policy.this.arn
}
main.tf
provider "aws" {
region = var.aws_region
profile = var.aws_profile
}
locals {
runner_label_prefix = "debian_amd64"
target_ecs_task_name = "github-actions-self-hosted-runner-debian-task"
git_org = "foo-organisation"
}
resource "aws_cloudwatch_event_rule" "this" {
name = "github-actions-trigger-runner"
description = "Rule to trigger ephemeral Github runner when a workflow queued event is received"
event_bus_name = data.aws_cloudwatch_event_bus.this.name
event_pattern = <<EOF
{
"detail": {
"organization": {
"login": ["${local.git_org}"]
},
"workflow_job": {
"labels": [{
"prefix": "${local.runner_label_prefix}"
}],
"status": ["queued"]
}
},
"source": ["github.com"]
}
EOF
}
resource "aws_cloudwatch_event_target" "this" {
target_id = local.target_ecs_task_name
event_bus_name = data.aws_cloudwatch_event_bus.this.name
rule = aws_cloudwatch_event_rule.this.name
arn = "arn:aws:ecs:us-east-1:xxxxxxxxxxxx:cluster/${var.cluster_name}"
role_arn = aws_iam_role.this.arn
ecs_target {
task_count = 1
task_definition_arn = "arn:aws:ecs:us-east-1:xxxxxxxxxxxx:task-definition/${local.target_ecs_task_name}"
enable_execute_command = false
enable_ecs_managed_tags = false
}
input_transformer {
input_paths = {
detail_org = "$.detail.organization.login",
detail_org_id = "$.detail.organization.id",
detail_repo_id = "$.detail.repository.id",
wf_job_run_id = "$.detail.workflow_job.run_id",
wf_job_run_attempt = "$.detail.workflow_job.run_attempt"
}
input_template = <<EOF
{
"containerOverrides": [{
"name": "github-actions-self-hosted-runner-debian-container",
"environment": [{
"name": "ORGANIZATION",
"value": "<detail_org>"
}, {
"name": "RUNNER_CONFIG_ARGS",
"value": "--unattended --replace --disableupdate --no-default-labels --name ${local.runner_label_prefix}_<detail_org_id>_<detail_repo_id>_<wf_job_run_id>_<wf_job_run_attempt> --ephemeral"
}, {
"name": "RUNNER_LABELS",
"value": "${local.runner_label_prefix}_<detail_org_id>_<detail_repo_id>_<wf_job_run_id>_<wf_job_run_attempt>"
}
]
}
]
}
EOF
}
}
Initialise Terraform:
terraform init
Generate plan:
terraform plan
Apply the plan:
terraform apply