Skip to content

Commit b6bdb84

Browse files
authored
feat: add first-class labels filter to eliminate red ❌ noise on unrelated label events (#28737)
1 parent 0a35ed2 commit b6bdb84

12 files changed

Lines changed: 454 additions & 8 deletions

actions/setup/js/aw_context.cjs

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -109,7 +109,8 @@ function resolveItemContext(payload) {
109109
* comment_node_id: string,
110110
* deployment_state: string,
111111
* otel_trace_id: string,
112-
* otel_parent_span_id: string
112+
* otel_parent_span_id: string,
113+
* trigger_label: string
113114
* }}
114115
* Properties:
115116
* - item_type: Kind of entity that triggered the workflow (issue, pull_request,
@@ -135,6 +136,9 @@ function resolveItemContext(payload) {
135136
* Empty string when OTLP is not configured or the parent setup step has
136137
* not yet run. Used by child workflow setup steps to link their setup
137138
* span as a child of the parent's setup span for proper trace hierarchy.
139+
* - trigger_label: Name of the label that triggered the workflow for labeled/unlabeled
140+
* events (e.g. pull_request_target, issues, pull_request with labeled type).
141+
* Empty string for events that do not carry label information.
138142
*/
139143
function buildAwContext() {
140144
const { item_type, item_number, comment_id, comment_node_id } = resolveItemContext(context.payload);
@@ -167,6 +171,10 @@ function buildAwContext() {
167171
// can link their setup span as a child of this span for proper trace hierarchy.
168172
// Empty string when OTLP is not configured or the parent setup step has not run yet.
169173
otel_parent_span_id: process.env.GITHUB_AW_OTEL_PARENT_SPAN_ID || "",
174+
// trigger_label is the label name from labeled/unlabeled events (pull_request_target,
175+
// issues, pull_request, etc.). Empty string for events without label data such as
176+
// workflow_dispatch, push, or schedule.
177+
trigger_label: context.payload?.label?.name ?? "",
170178
};
171179
}
172180

Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
# ADR-28737: First-Class `on.labels` Filter for Label-Triggered Workflow Events
2+
3+
**Date**: 2026-04-27
4+
**Status**: Draft
5+
**Deciders**: pelikhan, Copilot
6+
7+
---
8+
9+
## Part 1 — Narrative (Human-Friendly)
10+
11+
### Context
12+
13+
GitHub Actions does not provide a native label-name filter for events such as `pull_request_target` with `types: [labeled]`. Workflows that needed to respond only to specific labels had no clean mechanism — the only available workaround was to include an `exit 1` guard inside a workflow step. This caused every unrelated label-add event to show as a red ❌ failed run on CI dashboards rather than a clean gray ⊘ skip, degrading signal quality for teams monitoring pull request activity. The gh-aw compiler already provides analogous filters for contributor roles (`on.roles`) and bot identifiers (`on.bots`), establishing a precedent for injecting GitHub Actions `if:` expressions from frontmatter fields.
14+
15+
### Decision
16+
17+
We will add a first-class `on.labels` field to the gh-aw workflow frontmatter. When present, the compiler injects a job-level `if:` condition on the `pre_activation` job that skips the entire job when the triggering label does not match any of the listed names. Events that carry no label data (e.g., `workflow_dispatch`, `push`, `schedule`) are always allowed through via a `github.event.label.name == ''` guard, so non-labeled triggers are not inadvertently blocked. The field mirrors the existing `roles` and `bots` filter shape, accepting either a single string or an array. A `trigger_label` field is also added to the `aw_context` object so AI agents can read the triggering label name directly from their context payload.
18+
19+
### Alternatives Considered
20+
21+
#### Alternative 1: Step-level `exit 1` guard
22+
23+
Workflow authors could add an explicit shell guard (e.g., `if [[ "${{ github.event.label.name }}" != "panel-review" ]]; then exit 1; fi`) inside the first pre-activation step. This was the de-facto workaround before this ADR. It was rejected because `exit 1` marks the job as **failed** (red ❌) rather than **skipped** (gray ⊘), adding persistent noise to CI dashboards and causing confusion when authors see failures on label events they deliberately did not intend to handle.
24+
25+
#### Alternative 2: Step-level `if:` conditions injected on each generated step
26+
27+
The compiler could inject a step-level `if:` expression on every generated step rather than a single job-level condition. This was rejected because it produces a more complex compiled output, still allows the job header to show as running in the GitHub UI (not a clean skip), and does not achieve the gray ⊘ appearance that a job-level `if:` provides.
28+
29+
#### Alternative 3: Native GitHub Actions event filtering
30+
31+
GitHub Actions supports filtering by branch name or file path at the event trigger level but does not support filtering by label name. There is no native `on.pull_request_target.labels` equivalent. This alternative is not viable and was not seriously considered.
32+
33+
### Consequences
34+
35+
#### Positive
36+
- Unmatched label events now appear as ⊘ Skipped rather than ❌ Failed, eliminating CI dashboard noise on repositories that use many labels.
37+
- The implementation follows the established `roles`/`bots` compiler pattern, keeping the frontmatter API and internal compiler code consistent and predictable.
38+
- The `trigger_label` field in `aw_context` gives AI agents access to the triggering label name without requiring payload inspection.
39+
40+
#### Negative
41+
- The `on.labels` field is a gh-aw-specific frontmatter extension with no GitHub Actions native counterpart; users reading raw YAML may expect native behavior.
42+
- The `github.event.label.name == ''` pass-through guard is non-obvious in compiled output; readers may not immediately understand why non-labeled events are unconditionally allowed through.
43+
44+
#### Neutral
45+
- The `hasSafeEventsOnly()` event-counting function must explicitly exclude `labels` from its loop, mirroring the existing exclusions for `roles`, `bots`, `command`, `stop-after`, and `reaction`.
46+
- The JSON schema (`main_workflow_schema.json`) is updated to reflect `on.labels` as a `oneOf` string-or-array field, aligning static validation with runtime behavior.
47+
48+
---
49+
50+
## Part 2 — Normative Specification (RFC 2119)
51+
52+
> The key words **MUST**, **MUST NOT**, **REQUIRED**, **SHALL**, **SHALL NOT**, **SHOULD**, **SHOULD NOT**, **RECOMMENDED**, **MAY**, and **OPTIONAL** in this section are to be interpreted as described in [RFC 2119](https://www.rfc-editor.org/rfc/rfc2119).
53+
54+
### Label Filter Field
55+
56+
1. The `on.labels` frontmatter field **MUST** accept either a single non-empty string or a non-empty array of non-empty strings.
57+
2. Each label name value **MUST NOT** be an empty string.
58+
3. The `on.labels` array **MUST NOT** contain more than 50 entries.
59+
4. When `on.labels` is absent, the compiler **MUST NOT** inject any label-based `if:` condition into the compiled output.
60+
61+
### Compiled Output
62+
63+
1. When `on.labels` is set, the compiler **MUST** inject a job-level `if:` condition on the `pre_activation` job.
64+
2. The injected condition **MUST** evaluate to true when `github.event.label.name` is an empty string, passing through events that carry no label payload (e.g., `workflow_dispatch`, `push`, `schedule`).
65+
3. The injected condition **MUST** evaluate to true when `github.event.label.name` equals any of the label names specified in `on.labels`, using strict string equality (`==`).
66+
4. The injected condition **MUST NOT** use case-insensitive matching; label names **MUST** be matched exactly as specified in the frontmatter.
67+
5. When `on.labels` is combined with an existing job-level `if:` condition (e.g., from a top-level `if:` field), the compiler **MUST** combine both conditions using logical AND (`&&`), with the label condition as the first operand.
68+
69+
### Event Counting
70+
71+
1. The `labels` key under `on:` **MUST** be excluded from the event-type count computed by `hasSafeEventsOnly()`, consistent with the treatment of `roles`, `bots`, `command`, `stop-after`, and `reaction`.
72+
73+
### Agent Context
74+
75+
1. `buildAwContext()` **MUST** include a `trigger_label` field in the returned context object.
76+
2. `trigger_label` **MUST** be set to `context.payload?.label?.name` when a label payload is present, and **MUST** default to an empty string (`""`) for events without label data.
77+
78+
### Conformance
79+
80+
An implementation is considered conformant with this ADR if it satisfies all **MUST** and **MUST NOT** requirements above. Failure to meet any **MUST** or **MUST NOT** requirement constitutes non-conformance.
81+
82+
---
83+
84+
*This is a DRAFT ADR generated by the [Design Decision Gate](https://github.com/github/gh-aw/actions/runs/25006216146) workflow. The PR author must review, complete, and finalize this document before the PR can merge.*

docs/src/content/docs/reference/frontmatter-full.md

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -816,6 +816,22 @@ on:
816816
# Array of Bot identifier/name (e.g., 'dependabot[bot]', 'renovate[bot]',
817817
# 'github-actions[bot]')
818818

819+
# Filter workflows triggered by pull_request_target (or other labeled events) to
820+
# only fire when the triggering label matches one of these names. Generates a
821+
# job-level if: condition on the pre-activation job so unmatched label events show
822+
# as Skipped (⊘) rather than Failed (❌).
823+
# (optional)
824+
# This field supports multiple formats (oneOf):
825+
826+
# Option 1: Single label name that must match the triggering label (e.g.,
827+
# 'panel-review')
828+
labels: "example-value"
829+
830+
# Option 2: List of label names; the workflow fires when the triggering label
831+
# matches any entry.
832+
labels: []
833+
# Array items: Label name (e.g., 'panel-review', 'needs-triage')
834+
819835
# Environment name that requires manual approval before the workflow can run. Must
820836
# match a valid environment configured in the repository settings.
821837
# (optional)

pkg/parser/schemas/main_workflow_schema.json

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1828,6 +1828,24 @@
18281828
"description": "Bot identifier/name (e.g., 'dependabot[bot]', 'renovate[bot]', 'github-actions[bot]')"
18291829
}
18301830
},
1831+
"labels": {
1832+
"description": "Filter workflows triggered by pull_request_target (or other labeled events) to only fire when the triggering label matches one of these names. Generates a job-level if: condition on the pre-activation job so unmatched label events show as Skipped (\u2298) rather than Failed (\u274c).",
1833+
"oneOf": [
1834+
{
1835+
"$ref": "#/$defs/non_empty_string",
1836+
"description": "Single label name that must match the triggering label (e.g., 'panel-review')"
1837+
},
1838+
{
1839+
"type": "array",
1840+
"description": "List of label names; the workflow fires when the triggering label matches any entry.",
1841+
"items": {
1842+
"$ref": "#/$defs/non_empty_string"
1843+
},
1844+
"minItems": 1,
1845+
"maxItems": 50
1846+
}
1847+
]
1848+
},
18311849
"manual-approval": {
18321850
"type": "string",
18331851
"description": "Environment name that requires manual approval before the workflow can run. Must match a valid environment configured in the repository settings."
@@ -9335,6 +9353,11 @@
93359353
}
93369354
],
93379355
"$defs": {
9356+
"non_empty_string": {
9357+
"type": "string",
9358+
"minLength": 1,
9359+
"description": "A non-empty string value."
9360+
},
93389361
"templatable_boolean": {
93399362
"description": "A boolean value that may also be specified as a GitHub Actions expression string that resolves to a boolean at runtime (e.g. '${{ inputs.my-flag }}').",
93409363
"oneOf": [

pkg/workflow/compiler_jobs.go

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -268,10 +268,14 @@ func (c *Compiler) buildPreActivationAndActivationJobs(data *WorkflowData, front
268268
hasRateLimit := data.RateLimit != nil
269269
hasOnSteps := len(data.OnSteps) > 0
270270
hasOnNeeds := len(data.OnNeeds) > 0
271-
compilerJobsLog.Printf("Job configuration: needsPermissionCheck=%v, hasStopTime=%v, hasSkipIfMatch=%v, hasSkipIfNoMatch=%v, hasSkipRoles=%v, hasSkipBots=%v, hasCommand=%v, hasRateLimit=%v, hasOnSteps=%v, hasOnNeeds=%v", needsPermissionCheck, hasStopTime, hasSkipIfMatch, hasSkipIfNoMatch, hasSkipRoles, hasSkipBots, hasCommandTrigger, hasRateLimit, hasOnSteps, hasOnNeeds)
272-
273-
// Build pre-activation job if needed (combines membership checks, stop-time validation, skip-if-match check, skip-if-no-match check, skip-roles check, skip-bots check, rate limit check, command position check, and on.steps injection)
274-
if needsPermissionCheck || hasStopTime || hasSkipIfMatch || hasSkipIfNoMatch || hasSkipRoles || hasSkipBots || hasCommandTrigger || hasRateLimit || hasOnSteps || hasOnNeeds {
271+
hasLabelNames := len(data.LabelNames) > 0
272+
compilerJobsLog.Printf("Job configuration: needsPermissionCheck=%v, hasStopTime=%v, hasSkipIfMatch=%v, hasSkipIfNoMatch=%v, hasSkipRoles=%v, hasSkipBots=%v, hasCommand=%v, hasRateLimit=%v, hasOnSteps=%v, hasOnNeeds=%v, hasLabelNames=%v", needsPermissionCheck, hasStopTime, hasSkipIfMatch, hasSkipIfNoMatch, hasSkipRoles, hasSkipBots, hasCommandTrigger, hasRateLimit, hasOnSteps, hasOnNeeds, hasLabelNames)
273+
274+
// Build pre-activation job if needed. The job combines:
275+
// - membership checks, stop-time validation, skip-if-match/no-match checks
276+
// - skip-roles/bots checks, rate limit check, command position check
277+
// - on.steps injection, label-names filter
278+
if needsPermissionCheck || hasStopTime || hasSkipIfMatch || hasSkipIfNoMatch || hasSkipRoles || hasSkipBots || hasCommandTrigger || hasRateLimit || hasOnSteps || hasOnNeeds || hasLabelNames {
275279
compilerJobsLog.Print("Building pre-activation job")
276280
preActivationJob, err := c.buildPreActivationJob(data, needsPermissionCheck)
277281
if err != nil {

pkg/workflow/compiler_orchestrator_workflow.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -266,6 +266,7 @@ func (c *Compiler) extractAdditionalConfigurations(
266266

267267
workflowData.Roles = c.extractRoles(frontmatter)
268268
workflowData.Bots = c.extractBots(frontmatter)
269+
workflowData.LabelNames = c.extractLabelNames(frontmatter)
269270
workflowData.RateLimit = c.extractRateLimitConfig(frontmatter)
270271
workflowData.SkipRoles = c.mergeSkipRoles(c.extractSkipRoles(frontmatter), importsResult.MergedSkipRoles)
271272
workflowData.SkipBots = c.mergeSkipBots(c.extractSkipBots(frontmatter), importsResult.MergedSkipBots)

pkg/workflow/compiler_pre_activation_job.go

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -421,6 +421,22 @@ func (c *Compiler) buildPreActivationJob(data *WorkflowData, needsPermissionChec
421421
jobIfCondition = data.If
422422
}
423423

424+
// When labels is specified, add a job-level if: condition to the pre-activation job.
425+
// This causes the entire job to be skipped (gray ⊘) rather than failed (red ❌) when
426+
// the triggering label does not match, keeping CI dashboards noise-free.
427+
// workflow_dispatch is always allowed so manual runs are not blocked.
428+
if len(data.LabelNames) > 0 {
429+
labelIfCondition := buildLabelNamesCondition(data.LabelNames)
430+
if jobIfCondition != "" {
431+
jobIfCondition = RenderCondition(BuildAnd(
432+
&ExpressionNode{Expression: labelIfCondition},
433+
&ExpressionNode{Expression: jobIfCondition},
434+
))
435+
} else {
436+
jobIfCondition = labelIfCondition
437+
}
438+
}
439+
424440
// In script mode, explicitly add a cleanup step (mirrors post.js in dev/release/action mode).
425441
if c.actionMode.IsScript() {
426442
steps = append(steps, c.generateScriptModeCleanupStep())
@@ -440,6 +456,34 @@ func (c *Compiler) buildPreActivationJob(data *WorkflowData, needsPermissionChec
440456
return job, nil
441457
}
442458

459+
// buildLabelNamesCondition constructs the GitHub Actions if: expression for labels filtering.
460+
// The generated condition passes when:
461+
// - the event has no label object (github.event.label == null), which covers
462+
// workflow_dispatch, push, schedule, and any other non-labeled events, OR
463+
// - the triggering label name matches any of the specified names.
464+
//
465+
// Using github.event.label == null (rather than checking the name) is semantically
466+
// clearer and handles cases where GitHub Actions evaluates missing nested properties
467+
// as null before coercing to empty string.
468+
func buildLabelNamesCondition(labelNames []string) string {
469+
// Pass through events without a label payload.
470+
// github.event.label is null for workflow_dispatch, push, schedule, etc.
471+
noLabelEvent := ConditionNode(BuildEquals(
472+
BuildPropertyAccess("github.event.label"),
473+
BuildNullLiteral(),
474+
))
475+
476+
result := noLabelEvent
477+
for _, name := range labelNames {
478+
result = BuildOr(result, BuildEquals(
479+
BuildPropertyAccess("github.event.label.name"),
480+
BuildStringLiteral(name),
481+
))
482+
}
483+
484+
return result.Render()
485+
}
486+
443487
// generateReportSkipStep generates the "Report skip reason" step for the pre-activation job.
444488
// The step runs with if: always() and writes skip reasons to the GitHub Actions job summary
445489
// extractPreActivationCustomFields extracts custom steps and outputs from jobs.pre-activation field in frontmatter.

pkg/workflow/compiler_types.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -472,6 +472,7 @@ type WorkflowData struct {
472472
SandboxConfig *SandboxConfig // parsed sandbox configuration (AWF or SRT)
473473
SafeOutputs *SafeOutputsConfig // output configuration for automatic output routes
474474
MCPScripts *MCPScriptsConfig // mcp-scripts configuration for custom MCP tools
475+
LabelNames []string // label names that must match for pull_request_target labeled events (on.labels)
475476
Roles []string // permission levels required to trigger workflow
476477
Bots []string // allow list of bot identifiers that can trigger workflow
477478
RateLimit *RateLimitConfig // rate limiting configuration for workflow triggers

pkg/workflow/expression_nodes.go

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -169,7 +169,9 @@ type StringLiteralNode struct {
169169
}
170170

171171
func (s *StringLiteralNode) Render() string {
172-
return fmt.Sprintf("'%s'", s.Value)
172+
// GitHub Actions single-quoted strings escape embedded single quotes by doubling them.
173+
escaped := strings.ReplaceAll(s.Value, "'", "''")
174+
return fmt.Sprintf("'%s'", escaped)
173175
}
174176

175177
// BooleanLiteralNode represents a boolean literal value

pkg/workflow/expressions_test.go

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -273,6 +273,16 @@ func TestStringLiteralNode_Render(t *testing.T) {
273273
value: "issue-123",
274274
expected: "'issue-123'",
275275
},
276+
{
277+
name: "string with single quote",
278+
value: "can't-repro",
279+
expected: "'can''t-repro'",
280+
},
281+
{
282+
name: "string with multiple single quotes",
283+
value: "it's a bug (it's real)",
284+
expected: "'it''s a bug (it''s real)'",
285+
},
276286
}
277287

278288
for _, tt := range tests {

0 commit comments

Comments
 (0)