Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
10 changes: 9 additions & 1 deletion actions/setup/js/aw_context.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -109,7 +109,8 @@ function resolveItemContext(payload) {
* comment_node_id: string,
* deployment_state: string,
* otel_trace_id: string,
* otel_parent_span_id: string
* otel_parent_span_id: string,
* trigger_label: string
* }}
* Properties:
* - item_type: Kind of entity that triggered the workflow (issue, pull_request,
Expand All @@ -135,6 +136,9 @@ function resolveItemContext(payload) {
* Empty string when OTLP is not configured or the parent setup step has
* not yet run. Used by child workflow setup steps to link their setup
* span as a child of the parent's setup span for proper trace hierarchy.
* - trigger_label: Name of the label that triggered the workflow for labeled/unlabeled
* events (e.g. pull_request_target, issues, pull_request with labeled type).
* Empty string for events that do not carry label information.
*/
function buildAwContext() {
const { item_type, item_number, comment_id, comment_node_id } = resolveItemContext(context.payload);
Expand Down Expand Up @@ -167,6 +171,10 @@ function buildAwContext() {
// can link their setup span as a child of this span for proper trace hierarchy.
// Empty string when OTLP is not configured or the parent setup step has not run yet.
otel_parent_span_id: process.env.GITHUB_AW_OTEL_PARENT_SPAN_ID || "",
// trigger_label is the label name from labeled/unlabeled events (pull_request_target,
// issues, pull_request, etc.). Empty string for events without label data such as
// workflow_dispatch, push, or schedule.
trigger_label: context.payload?.label?.name ?? "",
};
}

Expand Down
84 changes: 84 additions & 0 deletions docs/adr/28737-first-class-labels-filter-for-labeled-events.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
# ADR-28737: First-Class `on.labels` Filter for Label-Triggered Workflow Events

**Date**: 2026-04-27
**Status**: Draft
**Deciders**: pelikhan, Copilot

---

## Part 1 — Narrative (Human-Friendly)

### Context

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.

### Decision

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.

### Alternatives Considered

#### Alternative 1: Step-level `exit 1` guard

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.

#### Alternative 2: Step-level `if:` conditions injected on each generated step

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.

#### Alternative 3: Native GitHub Actions event filtering

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.

### Consequences

#### Positive
- Unmatched label events now appear as ⊘ Skipped rather than ❌ Failed, eliminating CI dashboard noise on repositories that use many labels.
- The implementation follows the established `roles`/`bots` compiler pattern, keeping the frontmatter API and internal compiler code consistent and predictable.
- The `trigger_label` field in `aw_context` gives AI agents access to the triggering label name without requiring payload inspection.

#### Negative
- 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.
- 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.

#### Neutral
- The `hasSafeEventsOnly()` event-counting function must explicitly exclude `labels` from its loop, mirroring the existing exclusions for `roles`, `bots`, `command`, `stop-after`, and `reaction`.
- 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.

---

## Part 2 — Normative Specification (RFC 2119)

> 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).

### Label Filter Field

1. The `on.labels` frontmatter field **MUST** accept either a single non-empty string or a non-empty array of non-empty strings.
2. Each label name value **MUST NOT** be an empty string.
3. The `on.labels` array **MUST NOT** contain more than 50 entries.
4. When `on.labels` is absent, the compiler **MUST NOT** inject any label-based `if:` condition into the compiled output.

### Compiled Output

1. When `on.labels` is set, the compiler **MUST** inject a job-level `if:` condition on the `pre_activation` job.
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`).
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 (`==`).
4. The injected condition **MUST NOT** use case-insensitive matching; label names **MUST** be matched exactly as specified in the frontmatter.
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.

### Event Counting

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`.

### Agent Context

1. `buildAwContext()` **MUST** include a `trigger_label` field in the returned context object.
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.

### Conformance

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.

---

*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.*
16 changes: 16 additions & 0 deletions docs/src/content/docs/reference/frontmatter-full.md
Original file line number Diff line number Diff line change
Expand Up @@ -816,6 +816,22 @@ on:
# Array of Bot identifier/name (e.g., 'dependabot[bot]', 'renovate[bot]',
# 'github-actions[bot]')

# 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 (⊘) rather than Failed (❌).
# (optional)
# This field supports multiple formats (oneOf):

# Option 1: Single label name that must match the triggering label (e.g.,
# 'panel-review')
labels: "example-value"

# Option 2: List of label names; the workflow fires when the triggering label
# matches any entry.
labels: []
# Array items: Label name (e.g., 'panel-review', 'needs-triage')

# Environment name that requires manual approval before the workflow can run. Must
# match a valid environment configured in the repository settings.
# (optional)
Expand Down
23 changes: 23 additions & 0 deletions pkg/parser/schemas/main_workflow_schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -1828,6 +1828,24 @@
"description": "Bot identifier/name (e.g., 'dependabot[bot]', 'renovate[bot]', 'github-actions[bot]')"
}
},
"labels": {
"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).",
"oneOf": [
{
"$ref": "#/$defs/non_empty_string",
"description": "Single label name that must match the triggering label (e.g., 'panel-review')"
},
{
"type": "array",
"description": "List of label names; the workflow fires when the triggering label matches any entry.",
"items": {
"$ref": "#/$defs/non_empty_string"
},
"minItems": 1,
"maxItems": 50
}
]
},
"manual-approval": {
"type": "string",
"description": "Environment name that requires manual approval before the workflow can run. Must match a valid environment configured in the repository settings."
Expand Down Expand Up @@ -9335,6 +9353,11 @@
}
],
"$defs": {
"non_empty_string": {
"type": "string",
"minLength": 1,
"description": "A non-empty string value."
},
"templatable_boolean": {
"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 }}').",
"oneOf": [
Expand Down
12 changes: 8 additions & 4 deletions pkg/workflow/compiler_jobs.go
Original file line number Diff line number Diff line change
Expand Up @@ -268,10 +268,14 @@ func (c *Compiler) buildPreActivationAndActivationJobs(data *WorkflowData, front
hasRateLimit := data.RateLimit != nil
hasOnSteps := len(data.OnSteps) > 0
hasOnNeeds := len(data.OnNeeds) > 0
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)

// 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)
if needsPermissionCheck || hasStopTime || hasSkipIfMatch || hasSkipIfNoMatch || hasSkipRoles || hasSkipBots || hasCommandTrigger || hasRateLimit || hasOnSteps || hasOnNeeds {
hasLabelNames := len(data.LabelNames) > 0
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)

// Build pre-activation job if needed. The job combines:
// - membership checks, stop-time validation, skip-if-match/no-match checks
// - skip-roles/bots checks, rate limit check, command position check
// - on.steps injection, label-names filter
if needsPermissionCheck || hasStopTime || hasSkipIfMatch || hasSkipIfNoMatch || hasSkipRoles || hasSkipBots || hasCommandTrigger || hasRateLimit || hasOnSteps || hasOnNeeds || hasLabelNames {
compilerJobsLog.Print("Building pre-activation job")
preActivationJob, err := c.buildPreActivationJob(data, needsPermissionCheck)
if err != nil {
Expand Down
1 change: 1 addition & 0 deletions pkg/workflow/compiler_orchestrator_workflow.go
Original file line number Diff line number Diff line change
Expand Up @@ -266,6 +266,7 @@ func (c *Compiler) extractAdditionalConfigurations(

workflowData.Roles = c.extractRoles(frontmatter)
workflowData.Bots = c.extractBots(frontmatter)
workflowData.LabelNames = c.extractLabelNames(frontmatter)
workflowData.RateLimit = c.extractRateLimitConfig(frontmatter)
workflowData.SkipRoles = c.mergeSkipRoles(c.extractSkipRoles(frontmatter), importsResult.MergedSkipRoles)
workflowData.SkipBots = c.mergeSkipBots(c.extractSkipBots(frontmatter), importsResult.MergedSkipBots)
Expand Down
44 changes: 44 additions & 0 deletions pkg/workflow/compiler_pre_activation_job.go
Original file line number Diff line number Diff line change
Expand Up @@ -421,6 +421,22 @@ func (c *Compiler) buildPreActivationJob(data *WorkflowData, needsPermissionChec
jobIfCondition = data.If
}

// When labels is specified, add a job-level if: condition to the pre-activation job.
// This causes the entire job to be skipped (gray ⊘) rather than failed (red ❌) when
// the triggering label does not match, keeping CI dashboards noise-free.
// workflow_dispatch is always allowed so manual runs are not blocked.
if len(data.LabelNames) > 0 {
labelIfCondition := buildLabelNamesCondition(data.LabelNames)
if jobIfCondition != "" {
jobIfCondition = RenderCondition(BuildAnd(
&ExpressionNode{Expression: labelIfCondition},
&ExpressionNode{Expression: jobIfCondition},
))
} else {
jobIfCondition = labelIfCondition
}
}
Comment on lines +424 to +438
Copy link

Copilot AI Apr 27, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

on.labels filtering is implemented as a job-level if: on pre_activation, but pre_activation is only created when other checks are present. If a workflow sets on.labels on an otherwise “safe” trigger (no role check, stop-time, on.steps, etc.), pre_activation may not be generated at all, so the label filter would never apply and unrelated label events would still run. Consider treating LabelNames as a reason to create pre_activation (see buildPreActivationAndActivationJobs in compiler_jobs.go) or moving the label condition to a job that is always present (e.g., activation).

Copilot uses AI. Check for mistakes.

// In script mode, explicitly add a cleanup step (mirrors post.js in dev/release/action mode).
if c.actionMode.IsScript() {
steps = append(steps, c.generateScriptModeCleanupStep())
Expand All @@ -440,6 +456,34 @@ func (c *Compiler) buildPreActivationJob(data *WorkflowData, needsPermissionChec
return job, nil
}

// buildLabelNamesCondition constructs the GitHub Actions if: expression for labels filtering.
// The generated condition passes when:
// - the event has no label object (github.event.label == null), which covers
// workflow_dispatch, push, schedule, and any other non-labeled events, OR
// - the triggering label name matches any of the specified names.
//
// Using github.event.label == null (rather than checking the name) is semantically
// clearer and handles cases where GitHub Actions evaluates missing nested properties
// as null before coercing to empty string.
func buildLabelNamesCondition(labelNames []string) string {
// Pass through events without a label payload.
// github.event.label is null for workflow_dispatch, push, schedule, etc.
noLabelEvent := ConditionNode(BuildEquals(
BuildPropertyAccess("github.event.label"),
BuildNullLiteral(),
))

result := noLabelEvent
for _, name := range labelNames {
result = BuildOr(result, BuildEquals(
BuildPropertyAccess("github.event.label.name"),
BuildStringLiteral(name),
))
}
Comment on lines +477 to +482
Copy link

Copilot AI Apr 27, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Label names are rendered into single-quoted GitHub Actions string literals, but the string literal renderer doesn’t escape embedded ' characters. A label like can't-repro would generate an invalid expression (or change the expression meaning). Consider escaping single quotes in name before calling BuildStringLiteral (GitHub Actions uses doubled quotes inside single-quoted strings) or centralizing escaping in the string literal renderer.

Copilot uses AI. Check for mistakes.

return result.Render()
}

// generateReportSkipStep generates the "Report skip reason" step for the pre-activation job.
// The step runs with if: always() and writes skip reasons to the GitHub Actions job summary
// extractPreActivationCustomFields extracts custom steps and outputs from jobs.pre-activation field in frontmatter.
Expand Down
1 change: 1 addition & 0 deletions pkg/workflow/compiler_types.go
Original file line number Diff line number Diff line change
Expand Up @@ -472,6 +472,7 @@ type WorkflowData struct {
SandboxConfig *SandboxConfig // parsed sandbox configuration (AWF or SRT)
SafeOutputs *SafeOutputsConfig // output configuration for automatic output routes
MCPScripts *MCPScriptsConfig // mcp-scripts configuration for custom MCP tools
LabelNames []string // label names that must match for pull_request_target labeled events (on.labels)
Roles []string // permission levels required to trigger workflow
Bots []string // allow list of bot identifiers that can trigger workflow
RateLimit *RateLimitConfig // rate limiting configuration for workflow triggers
Expand Down
4 changes: 3 additions & 1 deletion pkg/workflow/expression_nodes.go
Original file line number Diff line number Diff line change
Expand Up @@ -169,7 +169,9 @@ type StringLiteralNode struct {
}

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

// BooleanLiteralNode represents a boolean literal value
Expand Down
10 changes: 10 additions & 0 deletions pkg/workflow/expressions_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -273,6 +273,16 @@ func TestStringLiteralNode_Render(t *testing.T) {
value: "issue-123",
expected: "'issue-123'",
},
{
name: "string with single quote",
value: "can't-repro",
expected: "'can''t-repro'",
},
{
name: "string with multiple single quotes",
value: "it's a bug (it's real)",
expected: "'it''s a bug (it''s real)'",
},
}

for _, tt := range tests {
Expand Down
Loading