Skip to content

Commit 4ccbde6

Browse files
feat: add bidirectionalLabelMatch option and deprecate exactMatch (#5031)
## Description Introduce a new `bidirectionalLabelMatch` option that performs strict two-way label matching (runner labels must equal workflow labels as a set). This preserves the existing `exactMatch` behavior (unidirectional subset check) to avoid breaking changes. The `bidirectionalLabelMatch` option requires labels to be identical in both directions. Previously, a runner with labels `[A, B, C, D]` would match a job requesting `[A, B, C]` when `exactMatch` was `true`. With `bidirectionalLabelMatch=true`, the runner will only match if the job requests exactly `[A, B, C, D]`. ``` Before: Job [A,B,C] + Runner [A,B,C,D] + exactMatch=true → Match After: Job [A,B,C] + Runner [A,B,C,D] + bidirectionalLabelMatch=true → No Match ``` `exactMatch` was supposed to have this behavior, but to avoid breaking changes, the variable will be deprecated to give users time to migrate. **Changes:** - Add `bidirectionalLabelMatch` to `MatcherConfig` interface (optional, defaults to `false`) - Update `canRunJob` to support bidirectional matching when enabled — now accepts both `bidirectionalLabelMatch` and `enableDynamicLabels` parameters; bidirectional matching correctly respects dynamic label sanitization - Deprecate `exactMatch` in Terraform variables with migration guidance - Add `bidirectionalLabelMatch` to multi-runner and webhook variable types - Add new root variable `enable_runner_bidirectional_label_match` - Add comprehensive test coverage for bidirectional matching **Migration:** To migrate, use `bidirectionalLabelMatch` instead of `exactMatch` in your runner configs. Then either: 1. Remove extra labels from runner configurations, or 2. Add the extra labels to your workflow job `runs-on` Co-authored-by: Stuart Pearson ## Test Plan - Added/updated unit tests in `dispatch.test.ts` covering bidirectional label matching, interaction with `exactMatch`, case-insensitivity, empty labels, and combined use with `enableDynamicLabels` — 36 tests passing ## Related Issues --------- Signed-off-by: Brend Smits <brend.smits@philips.com> Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
1 parent 37fa3a8 commit 4ccbde6

12 files changed

Lines changed: 135 additions & 26 deletions

File tree

README.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -128,10 +128,11 @@ Join our discord community via [this invite link](https://discord.gg/bxgXW8jJGh)
128128
| <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 |
129129
| <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 |
130130
| <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 |
131+
| <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 |
131132
| <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 |
132133
| <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 |
133134
| <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 |
134-
| <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 |
135+
| <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 |
135136
| <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 |
136137
| <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 |
137138
| <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 |

lambdas/functions/webhook/src/ConfigLoader.test.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -165,7 +165,7 @@ describe('ConfigLoader Tests', () => {
165165
});
166166

167167
await expect(ConfigWebhook.load()).rejects.toThrow(
168-
'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
168+
'Failed to load config: Failed to load parameter for matcherConfig from path /path/to/matcher/config: Failed to load matcher config',
169169
);
170170
});
171171

@@ -256,7 +256,7 @@ describe('ConfigLoader Tests', () => {
256256
});
257257

258258
await expect(ConfigWebhookEventBridge.load()).rejects.toThrow(
259-
'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
259+
'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',
260260
);
261261
});
262262
});
@@ -323,7 +323,7 @@ describe('ConfigLoader Tests', () => {
323323
});
324324

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

lambdas/functions/webhook/src/ConfigLoader.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -61,7 +61,7 @@ abstract class BaseConfig {
6161
this.loadProperty(propertyName, value);
6262
})
6363
.catch((error) => {
64-
const errorMessage = `Failed to load parameter for ${String(propertyName)} from path ${paramPath}: ${(error as Error).message}`; // eslint-disable-line max-len
64+
const errorMessage = `Failed to load parameter for ${String(propertyName)} from path ${paramPath}: ${(error as Error).message}`;
6565
this.configLoadingErrors.push(errorMessage);
6666
});
6767
}

lambdas/functions/webhook/src/runners/dispatch.test.ts

Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -279,6 +279,86 @@ describe('Dispatcher', () => {
279279
const runnerLabels = [['gpu']];
280280
expect(canRunJob(workflowLabels, runnerLabels, true)).toBe(false);
281281
});
282+
283+
it('should match when runner has more labels than workflow requests with exactMatch=true (unidirectional).', () => {
284+
const workflowLabels = ['self-hosted', 'linux', 'x64', 'staging', 'ubuntu-2404'];
285+
const runnerLabels = [['self-hosted', 'linux', 'x64', 'staging', 'ubuntu-2404', 'on-demand']];
286+
expect(canRunJob(workflowLabels, runnerLabels, true)).toBe(true);
287+
});
288+
289+
it('should match when labels are exactly identical with exactMatch=true.', () => {
290+
const workflowLabels = ['self-hosted', 'linux', 'on-demand'];
291+
const runnerLabels = [['self-hosted', 'linux', 'on-demand']];
292+
expect(canRunJob(workflowLabels, runnerLabels, true)).toBe(true);
293+
});
294+
295+
it('should match with exactMatch=true when labels are in different order.', () => {
296+
const workflowLabels = ['linux', 'self-hosted', 'x64'];
297+
const runnerLabels = [['self-hosted', 'linux', 'x64']];
298+
expect(canRunJob(workflowLabels, runnerLabels, true)).toBe(true);
299+
});
300+
301+
it('should match with exactMatch=true when labels are completely shuffled.', () => {
302+
const workflowLabels = ['x64', 'ubuntu-latest', 'self-hosted', 'linux'];
303+
const runnerLabels = [['self-hosted', 'linux', 'x64', 'ubuntu-latest']];
304+
expect(canRunJob(workflowLabels, runnerLabels, true)).toBe(true);
305+
});
306+
307+
it('should match with exactMatch=false when labels are in different order.', () => {
308+
const workflowLabels = ['gpu', 'self-hosted'];
309+
const runnerLabels = [['self-hosted', 'gpu']];
310+
expect(canRunJob(workflowLabels, runnerLabels, false)).toBe(true);
311+
});
312+
313+
// bidirectionalLabelMatch tests
314+
it('should NOT match when runner has more labels than workflow requests (bidirectionalLabelMatch=true).', () => {
315+
const workflowLabels = ['self-hosted', 'linux', 'x64', 'staging', 'ubuntu-2404'];
316+
const runnerLabels = [['self-hosted', 'linux', 'x64', 'staging', 'ubuntu-2404', 'on-demand']];
317+
expect(canRunJob(workflowLabels, runnerLabels, false, true)).toBe(false);
318+
});
319+
320+
it('should NOT match when workflow has more labels than runner (bidirectionalLabelMatch=true).', () => {
321+
const workflowLabels = ['self-hosted', 'linux', 'x64', 'ubuntu-latest', 'gpu'];
322+
const runnerLabels = [['self-hosted', 'linux', 'x64']];
323+
expect(canRunJob(workflowLabels, runnerLabels, false, true)).toBe(false);
324+
});
325+
326+
it('should match when labels are exactly identical with bidirectionalLabelMatch=true.', () => {
327+
const workflowLabels = ['self-hosted', 'linux', 'on-demand'];
328+
const runnerLabels = [['self-hosted', 'linux', 'on-demand']];
329+
expect(canRunJob(workflowLabels, runnerLabels, false, true)).toBe(true);
330+
});
331+
332+
it('should match with bidirectionalLabelMatch=true when labels are in different order.', () => {
333+
const workflowLabels = ['linux', 'self-hosted', 'x64'];
334+
const runnerLabels = [['self-hosted', 'linux', 'x64']];
335+
expect(canRunJob(workflowLabels, runnerLabels, false, true)).toBe(true);
336+
});
337+
338+
it('should match with bidirectionalLabelMatch=true when labels are completely shuffled.', () => {
339+
const workflowLabels = ['x64', 'ubuntu-latest', 'self-hosted', 'linux'];
340+
const runnerLabels = [['self-hosted', 'linux', 'x64', 'ubuntu-latest']];
341+
expect(canRunJob(workflowLabels, runnerLabels, false, true)).toBe(true);
342+
});
343+
344+
it('should match with bidirectionalLabelMatch=true ignoring case.', () => {
345+
const workflowLabels = ['Self-Hosted', 'Linux', 'X64'];
346+
const runnerLabels = [['self-hosted', 'linux', 'x64']];
347+
expect(canRunJob(workflowLabels, runnerLabels, false, true)).toBe(true);
348+
});
349+
350+
it('should NOT match empty workflow labels with bidirectionalLabelMatch=true.', () => {
351+
const workflowLabels: string[] = [];
352+
const runnerLabels = [['self-hosted', 'linux', 'x64']];
353+
expect(canRunJob(workflowLabels, runnerLabels, false, true)).toBe(false);
354+
});
355+
356+
it('bidirectionalLabelMatch takes precedence over exactMatch when both are true.', () => {
357+
const workflowLabels = ['self-hosted', 'linux', 'x64'];
358+
const runnerLabels = [['self-hosted', 'linux', 'x64', 'ubuntu-latest']];
359+
// exactMatch alone would accept this (runner has extra labels), but bidirectional should reject
360+
expect(canRunJob(workflowLabels, runnerLabels, true, true)).toBe(false);
361+
});
282362
});
283363

284364
describe('per-matcher dynamic labels handling', () => {

lambdas/functions/webhook/src/runners/dispatch.ts

Lines changed: 24 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -48,9 +48,11 @@ async function handleWorkflowJob(
4848
`Run ID: ${body.workflow_job.run_id}, Labels: ${JSON.stringify(body.workflow_job.labels)}`,
4949
);
5050

51-
// Sort queues by priority (exactMatch first), as before.
51+
// Sort queues by priority (exact/bidirectional match first), as before.
5252
matcherConfig.sort((a, b) => {
53-
return a.matcherConfig.exactMatch === b.matcherConfig.exactMatch ? 0 : a.matcherConfig.exactMatch ? -1 : 1;
53+
const aStrict = a.matcherConfig.bidirectionalLabelMatch || a.matcherConfig.exactMatch;
54+
const bStrict = b.matcherConfig.bidirectionalLabelMatch || b.matcherConfig.exactMatch;
55+
return aStrict === bStrict ? 0 : aStrict ? -1 : 1;
5456
});
5557

5658
const allLabels = body.workflow_job.labels;
@@ -61,7 +63,12 @@ async function handleWorkflowJob(
6163

6264
// 1. Collect all queues whose non-dynamic labels match the job.
6365
const matches: RunnerMatcherConfig[] = matcherConfig.filter((q) =>
64-
canRunJob(nonGhrLabels, q.matcherConfig.labelMatchers, q.matcherConfig.exactMatch),
66+
canRunJob(
67+
nonGhrLabels,
68+
q.matcherConfig.labelMatchers,
69+
q.matcherConfig.exactMatch,
70+
q.matcherConfig.bidirectionalLabelMatch,
71+
),
6572
);
6673

6774
if (matches.length === 0) {
@@ -190,12 +197,22 @@ export function canRunJob(
190197
workflowJobLabels: string[],
191198
runnerLabelsMatchers: string[][],
192199
workflowLabelCheckAll: boolean,
200+
bidirectionalLabelMatch = false,
193201
): boolean {
194202
const lowered = runnerLabelsMatchers.map((rl) => rl.map((l) => l.toLowerCase()));
195-
const matchLabels = workflowLabelCheckAll
196-
? lowered.some((rl) => workflowJobLabels.every((wl) => rl.includes(wl.toLowerCase())))
197-
: lowered.some((rl) => workflowJobLabels.some((wl) => rl.includes(wl.toLowerCase())));
198-
const match = workflowJobLabels.length === 0 ? !matchLabels : matchLabels;
203+
204+
let match: boolean;
205+
if (bidirectionalLabelMatch) {
206+
const workflowLabelsLower = workflowJobLabels.map((wl) => wl.toLowerCase());
207+
match = lowered.some(
208+
(rl) => workflowLabelsLower.every((wl) => rl.includes(wl)) && rl.every((r) => workflowLabelsLower.includes(r)),
209+
);
210+
} else {
211+
const matchLabels = workflowLabelCheckAll
212+
? lowered.some((rl) => workflowJobLabels.every((wl) => rl.includes(wl.toLowerCase())))
213+
: lowered.some((rl) => workflowJobLabels.some((wl) => rl.includes(wl.toLowerCase())));
214+
match = workflowJobLabels.length === 0 ? !matchLabels : matchLabels;
215+
}
199216

200217
logger.debug(
201218
`Received workflow job event with labels: '${JSON.stringify(workflowJobLabels)}'. The event does ${

lambdas/functions/webhook/src/sqs/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ export interface ActionRequestMessage {
2020
export interface MatcherConfig {
2121
labelMatchers: string[][];
2222
exactMatch: boolean;
23+
bidirectionalLabelMatch?: boolean;
2324
enableDynamicLabels?: boolean;
2425
ec2DynamicLabelsPolicy?: Ec2DynamicLabelsPolicy | null;
2526
}

main.tf

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -114,6 +114,7 @@ module "webhook" {
114114
matcherConfig : {
115115
labelMatchers : [local.runner_labels]
116116
exactMatch : var.enable_runner_workflow_job_labels_check_all
117+
bidirectionalLabelMatch : var.enable_runner_bidirectional_label_match
117118
enableDynamicLabels : var.enable_dynamic_labels
118119
ec2DynamicLabelsPolicy : var.ec2_dynamic_labels_policy
119120
}

modules/multi-runner/README.md

Lines changed: 1 addition & 1 deletion
Large diffs are not rendered by default.

modules/multi-runner/variables.tf

Lines changed: 8 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -200,11 +200,12 @@ variable "multi_runner_config" {
200200
})
201201
})
202202
matcherConfig = object({
203-
labelMatchers = list(list(string))
204-
exactMatch = optional(bool, false)
205-
priority = optional(number, 999)
206-
enableDynamicLabels = optional(bool, false)
207-
ec2DynamicLabelsPolicy = optional(any, null)
203+
labelMatchers = list(list(string))
204+
exactMatch = optional(bool, false)
205+
bidirectionalLabelMatch = optional(bool, false)
206+
priority = optional(number, 999)
207+
enableDynamicLabels = optional(bool, false)
208+
ec2DynamicLabelsPolicy = optional(any, null)
208209
})
209210
redrive_build_queue = optional(object({
210211
enabled = bool
@@ -276,7 +277,8 @@ variable "multi_runner_config" {
276277
}
277278
matcherConfig: {
278279
labelMatchers: "The list of list of labels supported by the runner configuration. `[[self-hosted, linux, x64, example]]`"
279-
exactMatch: "If set to true all labels in the workflow job must match the GitHub labels (os, architecture and `self-hosted`). When false if __any__ workflow label matches it will trigger the webhook."
280+
exactMatch: "DEPRECATED: Use `bidirectionalLabelMatch` 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__ workflow label matches it will trigger the webhook. Note: this only checks that workflow labels are a subset of runner labels, not the reverse."
281+
bidirectionalLabelMatch: "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 `exactMatch` which only checks that workflow labels are a subset of runner labels. When false, if __any__ workflow label matches it will trigger the webhook."
280282
priority: "If set it defines the priority of the matcher, the matcher with the lowest priority will be evaluated first. Default is 999, allowed values 0-999."
281283
enableDynamicLabels: "Experimental! When true the dispatcher allows `ghr-*` dynamic labels for jobs routed to this runner. Default false."
282284
ec2DynamicLabelsPolicy: "Optional policy for `ghr-ec2-*` labels evaluated by the dispatcher. Only effective when `enableDynamicLabels = true`. Jobs whose EC2 dynamic labels violate every matching runner's policy are rejected with a 202 (a warning is logged). Evaluation: keys in `blocked_keys` are always rejected; keys in `restricted_keys` are allowed only when their value passes the rule; unlisted keys are allowed. Schema: `{ blocked_keys = [<key>], restricted_keys = { <key> = { allowed = [globs], denied = [globs], max = number|string } } }`. Keys use the dynamic label suffix, e.g. `instance-type` for `ghr-ec2-instance-type`."

0 commit comments

Comments
 (0)