Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
e61882b
feat: add bidirectionalLabelMatch option and deprecate exactMatch
Brend-Smits Feb 13, 2026
3a222fb
fix(install-runner.sh): support Debian (#5027)
gmccue Mar 9, 2026
4507d17
fix: gracefully handle JIT config failures and terminate unconfigured…
Brend-Smits Mar 9, 2026
1c69978
chore(deps): bump zizmorcore/zizmor-action from 0.4.1 to 0.5.0 (#5034)
dependabot[bot] Mar 9, 2026
087d714
chore(lambda): bump @types/express from 5.0.3 to 5.0.6 in /lambdas (#…
dependabot[bot] Mar 9, 2026
169fe48
chore(deps): bump github/codeql-action from 4.31.9 to 4.32.4 (#5050)
dependabot[bot] Mar 9, 2026
dd4b3c2
chore(deps): bump google/osv-scanner-action/.github/workflows/osv-sca…
dependabot[bot] Mar 9, 2026
869a450
chore(deps): bump step-security/harden-runner from 2.14.0 to 2.14.2 (…
dependabot[bot] Mar 9, 2026
5121a98
chore(docs): bump mkdocs-material from 9.7.1 to 9.7.2 in /.github/wor…
dependabot[bot] Mar 9, 2026
d30ad38
chore(deps): bump the github group across 1 directory with 4 updates …
dependabot[bot] Mar 9, 2026
26506ee
fix(lambda): bump rollup from 4.46.2 to 4.59.0 in /lambdas (#5052)
dependabot[bot] Mar 9, 2026
53b513c
fix(lambda): bump the aws-powertools group in /lambdas with 4 updates…
dependabot[bot] Mar 9, 2026
0882f61
chore: add pull request template for better contribution guidelines (…
Brend-Smits Mar 9, 2026
e328854
chore(main): release 7.4.1 (#5033)
runners-releaser[bot] Mar 9, 2026
866eaf6
refactor(webhook): add persistent keys in logs (#5030)
edersonbrilhante Mar 10, 2026
84381ae
feat(lambdas): add batch SSM parameter fetching to reduce API calls (…
thomasnemer Mar 11, 2026
e78065d
feat(logging): add log_class parameter to runner log files configurat…
Brend-Smits Mar 11, 2026
ec2e785
chore(main): release 7.5.0 (#5063)
runners-releaser[bot] Mar 11, 2026
7ee2c4c
fix(logging): update log_class to log_group_class in CloudWatch agent…
Noah-mh Mar 18, 2026
6a63b36
feat(runner): add source parameter to distinguish between scale-up an…
stuartp44 Mar 30, 2026
5bd3128
docs: auto update terraform docs
Apr 1, 2026
818af81
Merge branch 'main' into fix/exact-match-was-not-really-exact-but-par…
Brend-Smits Apr 1, 2026
3195ee1
docs: auto update terraform docs
Apr 1, 2026
91aaf17
Merge branch 'main' into fix/exact-match-was-not-really-exact-but-par…
Brend-Smits Apr 14, 2026
6c68ca4
chore: merge main into fix/exact-match-was-not-really-exact-but-parti…
Copilot Jun 10, 2026
c6c9b5f
chore: resolve merge conflicts with main branch
Copilot Jun 10, 2026
3c9c2f1
docs: auto update terraform docs
Jun 11, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -127,10 +127,11 @@ Join our discord community via [this invite link](https://discord.gg/bxgXW8jJGh)
| <a name="input_enable_job_queued_check"></a> [enable\_job\_queued\_check](#input\_enable\_job\_queued\_check) | Only scale if the job event received by the scale up lambda is in the queued state. By default enabled for non ephemeral runners and disabled for ephemeral. Set this variable to overwrite the default behavior. | `bool` | `null` | no |
| <a name="input_enable_managed_runner_security_group"></a> [enable\_managed\_runner\_security\_group](#input\_enable\_managed\_runner\_security\_group) | Enables creation of the default managed security group. Unmanaged security groups can be specified via `runner_additional_security_group_ids`. | `bool` | `true` | no |
| <a name="input_enable_organization_runners"></a> [enable\_organization\_runners](#input\_enable\_organization\_runners) | Register runners to organization, instead of repo level | `bool` | `false` | no |
| <a name="input_enable_runner_bidirectional_label_match"></a> [enable\_runner\_bidirectional\_label\_match](#input\_enable\_runner\_bidirectional\_label\_match) | If set to true, the runner labels and workflow job labels must be an exact two-way match (same set, any order, no extras or missing labels). This is stricter than `enable_runner_workflow_job_labels_check_all` which only checks that workflow labels are a subset of runner labels. When false, if __any__ label matches it will trigger the webhook. | `bool` | `false` | no |
| <a name="input_enable_runner_binaries_syncer"></a> [enable\_runner\_binaries\_syncer](#input\_enable\_runner\_binaries\_syncer) | Option to disable the lambda to sync GitHub runner distribution, useful when using a pre-build AMI. | `bool` | `true` | no |
| <a name="input_enable_runner_detailed_monitoring"></a> [enable\_runner\_detailed\_monitoring](#input\_enable\_runner\_detailed\_monitoring) | Should detailed monitoring be enabled for the runner. Set this to true if you want to use detailed monitoring. See https://docs.aws.amazon.com/AWSEC2/latest/UserGuide/using-cloudwatch-new.html for details. | `bool` | `false` | no |
| <a name="input_enable_runner_on_demand_failover_for_errors"></a> [enable\_runner\_on\_demand\_failover\_for\_errors](#input\_enable\_runner\_on\_demand\_failover\_for\_errors) | Enable on-demand failover. For example to fall back to on demand when no spot capacity is available the variable can be set to `InsufficientInstanceCapacity`. When not defined the default behavior is to retry later. | `list(string)` | `[]` | no |
| <a name="input_enable_runner_workflow_job_labels_check_all"></a> [enable\_runner\_workflow\_job\_labels\_check\_all](#input\_enable\_runner\_workflow\_job\_labels\_check\_all) | If set to true all labels in the workflow job must match the GitHub labels (os, architecture and `self-hosted`). When false if __any__ label matches it will trigger the webhook. | `bool` | `true` | no |
| <a name="input_enable_runner_workflow_job_labels_check_all"></a> [enable\_runner\_workflow\_job\_labels\_check\_all](#input\_enable\_runner\_workflow\_job\_labels\_check\_all) | DEPRECATED: Use `enable_runner_bidirectional_label_match` instead. If set to true all labels in the workflow job must match the GitHub labels (os, architecture and `self-hosted`). When false if __any__ label matches it will trigger the webhook. Note: this only checks that workflow labels are a subset of runner labels, not the reverse. | `bool` | `true` | no |
| <a name="input_enable_ssm_on_runners"></a> [enable\_ssm\_on\_runners](#input\_enable\_ssm\_on\_runners) | Enable to allow access to the runner instances for debugging purposes via SSM. Note that this adds additional permissions to the runner instances. | `bool` | `false` | no |
| <a name="input_enable_user_data_debug_logging_runner"></a> [enable\_user\_data\_debug\_logging\_runner](#input\_enable\_user\_data\_debug\_logging\_runner) | Option to enable debug logging for user-data, this logs all secrets as well. | `bool` | `false` | no |
| <a name="input_enable_userdata"></a> [enable\_userdata](#input\_enable\_userdata) | Should the userdata script be enabled for the runner. Set this to false if you are using your own prebuilt AMI. | `bool` | `true` | no |
Expand Down
6 changes: 3 additions & 3 deletions lambdas/functions/webhook/src/ConfigLoader.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -165,7 +165,7 @@ describe('ConfigLoader Tests', () => {
});

await expect(ConfigWebhook.load()).rejects.toThrow(
'Failed to load config: Failed to load parameter for matcherConfig from path /path/to/matcher/config: Failed to load matcher config', // eslint-disable-line max-len
'Failed to load config: Failed to load parameter for matcherConfig from path /path/to/matcher/config: Failed to load matcher config',
);
});

Expand Down Expand Up @@ -256,7 +256,7 @@ describe('ConfigLoader Tests', () => {
});

await expect(ConfigWebhookEventBridge.load()).rejects.toThrow(
'Failed to load config: Environment variable for eventBusName is not set and no default value provided., Failed to load parameter for webhookSecret from path undefined: Parameter undefined not found', // eslint-disable-line max-len
'Failed to load config: Environment variable for eventBusName is not set and no default value provided., Failed to load parameter for webhookSecret from path undefined: Parameter undefined not found',
);
});
});
Expand Down Expand Up @@ -323,7 +323,7 @@ describe('ConfigLoader Tests', () => {
});

await expect(ConfigDispatcher.load()).rejects.toThrow(
'Failed to load config: Failed to load parameter for matcherConfig from path undefined: Parameter undefined not found', // eslint-disable-line max-len
'Failed to load config: Failed to load parameter for matcherConfig from path undefined: Parameter undefined not found',
);
});

Expand Down
2 changes: 1 addition & 1 deletion lambdas/functions/webhook/src/ConfigLoader.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,7 @@ abstract class BaseConfig {
this.loadProperty(propertyName, value);
})
.catch((error) => {
const errorMessage = `Failed to load parameter for ${String(propertyName)} from path ${paramPath}: ${(error as Error).message}`; // eslint-disable-line max-len
const errorMessage = `Failed to load parameter for ${String(propertyName)} from path ${paramPath}: ${(error as Error).message}`;
this.configLoadingErrors.push(errorMessage);
});
}
Expand Down
98 changes: 89 additions & 9 deletions lambdas/functions/webhook/src/runners/dispatch.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -219,7 +219,7 @@ describe('Dispatcher', () => {
it('should filter out ghr- labels when enableDynamicLabels is true.', () => {
const workflowLabels = ['self-hosted', 'linux', 'x64', 'ghr-ec2-instance-type:t3.large', 'ghr-run-id:12345'];
const runnerLabels = [['self-hosted', 'linux', 'x64']];
expect(canRunJob(workflowLabels, runnerLabels, true, true)).toBe(true);
expect(canRunJob(workflowLabels, runnerLabels, true, false, true)).toBe(true);
});

it('should NOT filter out ghr- labels when enableDynamicLabels is false.', () => {
Expand All @@ -233,47 +233,47 @@ describe('Dispatcher', () => {
it('should keep valid ghr- labels with allowed characters (alphanumeric, dot, dash, colon, slash)', () => {
const workflowLabels = ['self-hosted', 'ghr-ec2-instance-type:t3.large'];
const runnerLabels = [['self-hosted', 'ghr-ec2-instance-type:t3.large']];
expect(canRunJob(workflowLabels, runnerLabels, true, true)).toBe(true);
expect(canRunJob(workflowLabels, runnerLabels, true, false, true)).toBe(true);
});

it('should keep ghr- labels with path-like values containing slashes', () => {
const workflowLabels = ['self-hosted', 'ghr-image:my/custom/image'];
const runnerLabels = [['self-hosted', 'ghr-image:my/custom/image']];
expect(canRunJob(workflowLabels, runnerLabels, true, true)).toBe(true);
expect(canRunJob(workflowLabels, runnerLabels, true, false, true)).toBe(true);
});

it('should strip ghr- labels that exceed 128 characters', () => {
const longLabel = 'ghr-' + 'a'.repeat(125); // 129 chars total, exceeds 128
const workflowLabels = ['self-hosted', 'linux', longLabel];
const runnerLabels = [['self-hosted', 'linux']];
// Long label is stripped, remaining labels match
expect(canRunJob(workflowLabels, runnerLabels, true, true)).toBe(true);
expect(canRunJob(workflowLabels, runnerLabels, true, false, true)).toBe(true);
});

it('should keep ghr- labels that are exactly 128 characters', () => {
const exactLabel = 'ghr-' + 'a'.repeat(124); // exactly 128 chars
const workflowLabels = ['self-hosted', exactLabel];
const runnerLabels = [['self-hosted', exactLabel]];
expect(canRunJob(workflowLabels, runnerLabels, true, true)).toBe(true);
expect(canRunJob(workflowLabels, runnerLabels, true, false, true)).toBe(true);
});

it('should strip ghr- labels with invalid characters (spaces)', () => {
const workflowLabels = ['self-hosted', 'linux', 'ghr-bad label'];
const runnerLabels = [['self-hosted', 'linux']];
// Invalid label is stripped, remaining labels match
expect(canRunJob(workflowLabels, runnerLabels, true, true)).toBe(true);
expect(canRunJob(workflowLabels, runnerLabels, true, false, true)).toBe(true);
});

it('should strip ghr- labels with invalid characters (special chars)', () => {
const workflowLabels = ['self-hosted', 'linux', 'ghr-inject;rm -rf'];
const runnerLabels = [['self-hosted', 'linux']];
expect(canRunJob(workflowLabels, runnerLabels, true, true)).toBe(true);
expect(canRunJob(workflowLabels, runnerLabels, true, false, true)).toBe(true);
});

it('should never strip non-ghr labels regardless of content', () => {
const workflowLabels = ['self-hosted', 'linux', 'x64'];
const runnerLabels = [['self-hosted', 'linux', 'x64']];
expect(canRunJob(workflowLabels, runnerLabels, true, true)).toBe(true);
expect(canRunJob(workflowLabels, runnerLabels, true, false, true)).toBe(true);
});

it('should handle a mix of valid ghr-, invalid ghr-, and regular labels', () => {
Expand All @@ -286,7 +286,87 @@ describe('Dispatcher', () => {
longLabel, // too long, stripped
];
const runnerLabels = [['self-hosted', 'linux', 'ghr-valid:value']];
expect(canRunJob(workflowLabels, runnerLabels, true, true)).toBe(true);
expect(canRunJob(workflowLabels, runnerLabels, true, false, true)).toBe(true);
});

it('should match when runner has more labels than workflow requests with exactMatch=true (unidirectional).', () => {
const workflowLabels = ['self-hosted', 'linux', 'x64', 'staging', 'ubuntu-2404'];
const runnerLabels = [['self-hosted', 'linux', 'x64', 'staging', 'ubuntu-2404', 'on-demand']];
expect(canRunJob(workflowLabels, runnerLabels, true)).toBe(true);
});

it('should match when labels are exactly identical with exactMatch=true.', () => {
const workflowLabels = ['self-hosted', 'linux', 'on-demand'];
const runnerLabels = [['self-hosted', 'linux', 'on-demand']];
expect(canRunJob(workflowLabels, runnerLabels, true)).toBe(true);
});

it('should match with exactMatch=true when labels are in different order.', () => {
const workflowLabels = ['linux', 'self-hosted', 'x64'];
const runnerLabels = [['self-hosted', 'linux', 'x64']];
expect(canRunJob(workflowLabels, runnerLabels, true)).toBe(true);
});

it('should match with exactMatch=true when labels are completely shuffled.', () => {
const workflowLabels = ['x64', 'ubuntu-latest', 'self-hosted', 'linux'];
const runnerLabels = [['self-hosted', 'linux', 'x64', 'ubuntu-latest']];
expect(canRunJob(workflowLabels, runnerLabels, true)).toBe(true);
});

it('should match with exactMatch=false when labels are in different order.', () => {
const workflowLabels = ['gpu', 'self-hosted'];
const runnerLabels = [['self-hosted', 'gpu']];
expect(canRunJob(workflowLabels, runnerLabels, false)).toBe(true);
});

// bidirectionalLabelMatch tests
it('should NOT match when runner has more labels than workflow requests (bidirectionalLabelMatch=true).', () => {
const workflowLabels = ['self-hosted', 'linux', 'x64', 'staging', 'ubuntu-2404'];
const runnerLabels = [['self-hosted', 'linux', 'x64', 'staging', 'ubuntu-2404', 'on-demand']];
expect(canRunJob(workflowLabels, runnerLabels, false, true)).toBe(false);
});

it('should NOT match when workflow has more labels than runner (bidirectionalLabelMatch=true).', () => {
const workflowLabels = ['self-hosted', 'linux', 'x64', 'ubuntu-latest', 'gpu'];
const runnerLabels = [['self-hosted', 'linux', 'x64']];
expect(canRunJob(workflowLabels, runnerLabels, false, true)).toBe(false);
});

it('should match when labels are exactly identical with bidirectionalLabelMatch=true.', () => {
const workflowLabels = ['self-hosted', 'linux', 'on-demand'];
const runnerLabels = [['self-hosted', 'linux', 'on-demand']];
expect(canRunJob(workflowLabels, runnerLabels, false, true)).toBe(true);
});

it('should match with bidirectionalLabelMatch=true when labels are in different order.', () => {
const workflowLabels = ['linux', 'self-hosted', 'x64'];
const runnerLabels = [['self-hosted', 'linux', 'x64']];
expect(canRunJob(workflowLabels, runnerLabels, false, true)).toBe(true);
});

it('should match with bidirectionalLabelMatch=true when labels are completely shuffled.', () => {
const workflowLabels = ['x64', 'ubuntu-latest', 'self-hosted', 'linux'];
const runnerLabels = [['self-hosted', 'linux', 'x64', 'ubuntu-latest']];
expect(canRunJob(workflowLabels, runnerLabels, false, true)).toBe(true);
});

it('should match with bidirectionalLabelMatch=true ignoring case.', () => {
const workflowLabels = ['Self-Hosted', 'Linux', 'X64'];
const runnerLabels = [['self-hosted', 'linux', 'x64']];
expect(canRunJob(workflowLabels, runnerLabels, false, true)).toBe(true);
});

it('should NOT match empty workflow labels with bidirectionalLabelMatch=true.', () => {
const workflowLabels: string[] = [];
const runnerLabels = [['self-hosted', 'linux', 'x64']];
expect(canRunJob(workflowLabels, runnerLabels, false, true)).toBe(false);
});

it('bidirectionalLabelMatch takes precedence over exactMatch when both are true.', () => {
const workflowLabels = ['self-hosted', 'linux', 'x64'];
const runnerLabels = [['self-hosted', 'linux', 'x64', 'ubuntu-latest']];
// exactMatch alone would accept this (runner has extra labels), but bidirectional should reject
expect(canRunJob(workflowLabels, runnerLabels, true, true)).toBe(false);
});
});
});
Expand Down
27 changes: 20 additions & 7 deletions lambdas/functions/webhook/src/runners/dispatch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,16 +43,19 @@ async function handleWorkflowJob(
`Job ID: ${body.workflow_job.id}, Job Name: ${body.workflow_job.name}, ` +
`Run ID: ${body.workflow_job.run_id}, Labels: ${JSON.stringify(body.workflow_job.labels)}`,
);
// sort the queuesConfig by order of matcher config exact match, with all true matches lined up ahead.
// sort the queuesConfig by order of matcher config exact/bidirectional match, with all true matches lined up ahead.
matcherConfig.sort((a, b) => {
return a.matcherConfig.exactMatch === b.matcherConfig.exactMatch ? 0 : a.matcherConfig.exactMatch ? -1 : 1;
const aStrict = a.matcherConfig.bidirectionalLabelMatch || a.matcherConfig.exactMatch;
const bStrict = b.matcherConfig.bidirectionalLabelMatch || b.matcherConfig.exactMatch;
return aStrict === bStrict ? 0 : aStrict ? -1 : 1;
});
for (const queue of matcherConfig) {
if (
canRunJob(
body.workflow_job.labels,
queue.matcherConfig.labelMatchers,
queue.matcherConfig.exactMatch,
queue.matcherConfig.bidirectionalLabelMatch,
enableDynamicLabels,
)
) {
Expand Down Expand Up @@ -110,18 +113,28 @@ export function canRunJob(
workflowJobLabels: string[],
runnerLabelsMatchers: string[][],
workflowLabelCheckAll: boolean,
enableDynamicLabels: boolean,
bidirectionalLabelMatch = false,
enableDynamicLabels = false,
): boolean {
// Filter out ghr- labels only and sanitize them if dynamic labels is enabled, otherwise keep all labels as is for matching.
const sanitizedLabels = enableDynamicLabels ? sanitizeGhrLabels(workflowJobLabels) : workflowJobLabels;

runnerLabelsMatchers = runnerLabelsMatchers.map((runnerLabel) => {
return runnerLabel.map((label) => label.toLowerCase());
});
const matchLabels = workflowLabelCheckAll
? runnerLabelsMatchers.some((rl) => sanitizedLabels.every((wl) => rl.includes(wl.toLowerCase())))
: runnerLabelsMatchers.some((rl) => sanitizedLabels.some((wl) => rl.includes(wl.toLowerCase())));
const match = sanitizedLabels.length === 0 ? !matchLabels : matchLabels;

let match: boolean;
if (bidirectionalLabelMatch) {
const sanitizedLabelsLower = sanitizedLabels.map((wl) => wl.toLowerCase());
match = runnerLabelsMatchers.some(
(rl) => sanitizedLabelsLower.every((wl) => rl.includes(wl)) && rl.every((r) => sanitizedLabelsLower.includes(r)),
);
} else {
const matchLabels = workflowLabelCheckAll
? runnerLabelsMatchers.some((rl) => sanitizedLabels.every((wl) => rl.includes(wl.toLowerCase())))
: runnerLabelsMatchers.some((rl) => sanitizedLabels.some((wl) => rl.includes(wl.toLowerCase())));
match = sanitizedLabels.length === 0 ? !matchLabels : matchLabels;
}

logger.debug(
`Received workflow job event with labels: '${JSON.stringify(workflowJobLabels)}'. The event does ${
Expand Down
1 change: 1 addition & 0 deletions lambdas/functions/webhook/src/sqs/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ export interface ActionRequestMessage {
export interface MatcherConfig {
labelMatchers: string[][];
exactMatch: boolean;
bidirectionalLabelMatch?: boolean;
}

export type RunnerConfig = RunnerMatcherConfig[];
Expand Down
Loading