From e86baec867080bb8a6a68828281a0f4e6d7f5d03 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 27 Apr 2026 13:47:54 +0000 Subject: [PATCH 01/10] Initial plan From ebfa81e5cff790d800c985c654ce9ee889bb1f30 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 27 Apr 2026 14:11:59 +0000 Subject: [PATCH 02/10] feat: add label-names filter for pull_request_target labeled events (#issue) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a first-class `label-names` filter at the `on:` level that generates a job-level `if:` condition on the `pre_activation` job. When the triggering label does not match, the entire pre-activation job is skipped (gray ⊘) rather than failing (red ❌), eliminating noise for unrelated label events. Usage: on: pull_request_target: types: [labeled] label-names: [panel-review] # single label or array Generated condition: if: github.event.label.name == 'panel-review' || github.event_name == 'workflow_dispatch' Agent-Logs-Url: https://github.com/github/gh-aw/sessions/69ff1987-fb41-4159-bfd6-d039703c41a2 Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> --- .../docs/reference/frontmatter-full.md | 16 ++ pkg/parser/schemas/main_workflow_schema.json | 21 ++ .../compiler_orchestrator_workflow.go | 1 + pkg/workflow/compiler_pre_activation_job.go | 48 ++++ pkg/workflow/compiler_types.go | 1 + pkg/workflow/label_names_test.go | 225 ++++++++++++++++++ pkg/workflow/role_checks.go | 25 +- 7 files changed, 335 insertions(+), 2 deletions(-) create mode 100644 pkg/workflow/label_names_test.go diff --git a/docs/src/content/docs/reference/frontmatter-full.md b/docs/src/content/docs/reference/frontmatter-full.md index 3d056cd54bd..8ad92cbbaf3 100644 --- a/docs/src/content/docs/reference/frontmatter-full.md +++ b/docs/src/content/docs/reference/frontmatter-full.md @@ -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') + label-names: "example-value" + + # Option 2: List of label names; the workflow fires when the triggering label + # matches any entry. + label-names: [] + # 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) diff --git a/pkg/parser/schemas/main_workflow_schema.json b/pkg/parser/schemas/main_workflow_schema.json index 060101e3e13..71c7a7c6c43 100644 --- a/pkg/parser/schemas/main_workflow_schema.json +++ b/pkg/parser/schemas/main_workflow_schema.json @@ -1828,6 +1828,27 @@ "description": "Bot identifier/name (e.g., 'dependabot[bot]', 'renovate[bot]', 'github-actions[bot]')" } }, + "label-names": { + "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 (\u29b8) rather than Failed (\u274c).", + "oneOf": [ + { + "type": "string", + "minLength": 1, + "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": { + "type": "string", + "minLength": 1, + "description": "Label name (e.g., 'panel-review', 'needs-triage')" + }, + "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." diff --git a/pkg/workflow/compiler_orchestrator_workflow.go b/pkg/workflow/compiler_orchestrator_workflow.go index 93a6c740c9d..d39ad0389bd 100644 --- a/pkg/workflow/compiler_orchestrator_workflow.go +++ b/pkg/workflow/compiler_orchestrator_workflow.go @@ -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) diff --git a/pkg/workflow/compiler_pre_activation_job.go b/pkg/workflow/compiler_pre_activation_job.go index dcde9ff6bfb..e765017121d 100644 --- a/pkg/workflow/compiler_pre_activation_job.go +++ b/pkg/workflow/compiler_pre_activation_job.go @@ -421,6 +421,22 @@ func (c *Compiler) buildPreActivationJob(data *WorkflowData, needsPermissionChec jobIfCondition = data.If } + // When label-names 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 + } + } + // 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()) @@ -440,6 +456,38 @@ func (c *Compiler) buildPreActivationJob(data *WorkflowData, needsPermissionChec return job, nil } +// buildLabelNamesCondition constructs the GitHub Actions if: expression for label-names filtering. +// The generated condition passes when: +// - the triggering label name matches any of the specified names, OR +// - the event is workflow_dispatch (so manual runs are never blocked). +func buildLabelNamesCondition(labelNames []string) string { + var labelChecks []ConditionNode + for _, name := range labelNames { + labelChecks = append(labelChecks, BuildEquals( + BuildPropertyAccess("github.event.label.name"), + BuildStringLiteral(name), + )) + } + + var labelMatch ConditionNode + if len(labelChecks) == 1 { + labelMatch = labelChecks[0] + } else { + labelMatch = labelChecks[0] + for i := 1; i < len(labelChecks); i++ { + labelMatch = BuildOr(labelMatch, labelChecks[i]) + } + } + + // Always allow workflow_dispatch so manual runs are not blocked by the label filter. + workflowDispatch := BuildEquals( + BuildPropertyAccess("github.event_name"), + BuildStringLiteral("workflow_dispatch"), + ) + + return BuildOr(labelMatch, workflowDispatch).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. diff --git a/pkg/workflow/compiler_types.go b/pkg/workflow/compiler_types.go index 1c9d7d6a2f0..f2c2ffcc72a 100644 --- a/pkg/workflow/compiler_types.go +++ b/pkg/workflow/compiler_types.go @@ -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.label-names) 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 diff --git a/pkg/workflow/label_names_test.go b/pkg/workflow/label_names_test.go new file mode 100644 index 00000000000..6ad71fbe462 --- /dev/null +++ b/pkg/workflow/label_names_test.go @@ -0,0 +1,225 @@ +//go:build !integration + +package workflow + +import ( + "os" + "strings" + "testing" + + "github.com/github/gh-aw/pkg/stringutil" + "github.com/github/gh-aw/pkg/testutil" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestExtractLabelNames(t *testing.T) { + compiler := &Compiler{} + + tests := []struct { + name string + frontmatter map[string]any + expected []string + }{ + { + name: "single label name as string", + frontmatter: map[string]any{ + "on": map[string]any{ + "pull_request_target": map[string]any{ + "types": []any{"labeled"}, + }, + "label-names": "panel-review", + }, + }, + expected: []string{"panel-review"}, + }, + { + name: "multiple label names as array", + frontmatter: map[string]any{ + "on": map[string]any{ + "pull_request_target": map[string]any{ + "types": []any{"labeled"}, + }, + "label-names": []any{"panel-review", "needs-triage"}, + }, + }, + expected: []string{"panel-review", "needs-triage"}, + }, + { + name: "no label-names field returns nil", + frontmatter: map[string]any{ + "on": map[string]any{ + "pull_request_target": map[string]any{ + "types": []any{"labeled"}, + }, + }, + }, + expected: nil, + }, + { + name: "no on section returns nil", + frontmatter: map[string]any{}, + expected: nil, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := compiler.extractLabelNames(tt.frontmatter) + assert.Equal(t, tt.expected, result, "extractLabelNames should return expected label names") + }) + } +} + +func TestBuildLabelNamesCondition(t *testing.T) { + tests := []struct { + name string + labelNames []string + expected string + }{ + { + name: "single label name", + labelNames: []string{"panel-review"}, + expected: "github.event.label.name == 'panel-review' || github.event_name == 'workflow_dispatch'", + }, + { + name: "multiple label names", + labelNames: []string{"panel-review", "needs-triage"}, + expected: "github.event.label.name == 'panel-review' || github.event.label.name == 'needs-triage' || github.event_name == 'workflow_dispatch'", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := buildLabelNamesCondition(tt.labelNames) + assert.Equal(t, tt.expected, result, "buildLabelNamesCondition should return expected condition") + }) + } +} + +// TestLabelNamesPreActivationFilter verifies that on.label-names generates a job-level +// if: condition on the pre_activation job that skips the workflow when the triggering +// label does not match (gray ⊘ rather than red ❌). +func TestLabelNamesPreActivationFilter(t *testing.T) { + tmpDir := testutil.TempDir(t, "label-names-filter-test") + compiler := NewCompiler() + + tests := []struct { + name string + frontmatter string + expectedIf string + shouldHaveIf bool + }{ + { + name: "pull_request_target with single label-names", + frontmatter: `--- +on: + pull_request_target: + types: [labeled] + label-names: panel-review + +permissions: + contents: read + pull-requests: read + issues: read + +strict: false +tools: + github: + allowed: [get_pull_request] +---`, + expectedIf: "github.event.label.name == 'panel-review' || github.event_name == 'workflow_dispatch'", + shouldHaveIf: true, + }, + { + name: "pull_request_target with multiple label-names", + frontmatter: `--- +on: + pull_request_target: + types: [labeled] + label-names: [panel-review, needs-triage] + +permissions: + contents: read + pull-requests: read + issues: read + +strict: false +tools: + github: + allowed: [get_pull_request] +---`, + expectedIf: "github.event.label.name == 'panel-review'", + shouldHaveIf: true, + }, + { + name: "pull_request_target without label-names has no if condition from label filter", + frontmatter: `--- +on: + pull_request_target: + types: [labeled] + +permissions: + contents: read + pull-requests: read + issues: read + +strict: false +tools: + github: + allowed: [get_pull_request] +---`, + expectedIf: "github.event.label.name", + shouldHaveIf: false, + }, + { + name: "issues with label-names generates pre-activation if condition", + frontmatter: `--- +on: + issues: + types: [labeled] + label-names: [bug, enhancement] + +permissions: + contents: read + issues: read + pull-requests: read + +strict: false +tools: + github: + allowed: [issue_read] +---`, + expectedIf: "github.event.label.name == 'bug' || github.event.label.name == 'enhancement' || github.event_name == 'workflow_dispatch'", + shouldHaveIf: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + testFile := tmpDir + "/test-" + strings.ReplaceAll(tt.name, " ", "-") + ".md" + content := tt.frontmatter + "\n\n# Test Workflow\n\nTest label-names filter." + require.NoError(t, os.WriteFile(testFile, []byte(content), 0644), "should write test file") + + err := compiler.CompileWorkflow(testFile) + require.NoError(t, err, "should compile workflow successfully") + + lockFile := stringutil.MarkdownToLockFile(testFile) + lockBytes, err := os.ReadFile(lockFile) + require.NoError(t, err, "should read lock file") + lockContent := string(lockBytes) + + // Clean up + os.Remove(testFile) + os.Remove(lockFile) + + if tt.shouldHaveIf { + assert.Contains(t, lockContent, tt.expectedIf, + "pre_activation job should have if condition matching label filter") + } else { + assert.NotContains(t, lockContent, tt.expectedIf, + "pre_activation job should not have label-name if condition when label-names not specified") + } + }) + } +} diff --git a/pkg/workflow/role_checks.go b/pkg/workflow/role_checks.go index 28761bb3550..4c174d6c617 100644 --- a/pkg/workflow/role_checks.go +++ b/pkg/workflow/role_checks.go @@ -91,6 +91,24 @@ func (c *Compiler) generateRateLimitCheck(data *WorkflowData, steps []string) [] return steps } +// extractLabelNames extracts the 'label-names' field from frontmatter. +// When set, the pre-activation job emits a job-level if: condition that skips the workflow +// (gray ⊘ rather than red ❌) when the triggering label does not match. +func (c *Compiler) extractLabelNames(frontmatter map[string]any) []string { + if onValue, exists := frontmatter["on"]; exists { + if onMap, ok := onValue.(map[string]any); ok { + if labelNamesValue, hasLabelNames := onMap["label-names"]; hasLabelNames { + names := parseOptionalStringSliceField(labelNamesValue, "on.label-names") + if len(names) > 0 { + roleLog.Printf("Extracted %d label-names: %v", len(names), names) + return names + } + } + } + } + return nil +} + // extractRoles extracts the 'roles' field from frontmatter to determine permission requirements func (c *Compiler) extractRoles(frontmatter map[string]any) []string { // Check on.roles @@ -369,8 +387,8 @@ func (c *Compiler) hasSafeEventsOnly(data *WorkflowData, frontmatter map[string] for eventName := range onMap { // Skip command events as they are handled separately // Skip stop-after and reaction as they are not event types - // Skip roles and bots as they are configuration, not event types - if eventName == "command" || eventName == "stop-after" || eventName == "reaction" || eventName == "roles" || eventName == "bots" { + // Skip roles, bots, and label-names as they are configuration, not event types + if eventName == "command" || eventName == "stop-after" || eventName == "reaction" || eventName == "roles" || eventName == "bots" || eventName == "label-names" { continue } @@ -408,6 +426,9 @@ func (c *Compiler) hasSafeEventsOnly(data *WorkflowData, frontmatter map[string] if _, hasBots := onMap["bots"]; hasBots { eventCount-- } + if _, hasLabelNames := onMap["label-names"]; hasLabelNames { + eventCount-- + } // Special handling for workflow_dispatch: // workflow_dispatch can be triggered by users with "write" access, From 8cad88d3de861a109704a0b02214c7f804aea8fe Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 27 Apr 2026 14:13:32 +0000 Subject: [PATCH 03/10] fix: improve test assertion for multiple label-names to check full condition string Agent-Logs-Url: https://github.com/github/gh-aw/sessions/69ff1987-fb41-4159-bfd6-d039703c41a2 Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> --- pkg/workflow/label_names_test.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pkg/workflow/label_names_test.go b/pkg/workflow/label_names_test.go index 6ad71fbe462..84a741b7b2e 100644 --- a/pkg/workflow/label_names_test.go +++ b/pkg/workflow/label_names_test.go @@ -149,7 +149,7 @@ tools: github: allowed: [get_pull_request] ---`, - expectedIf: "github.event.label.name == 'panel-review'", + expectedIf: "github.event.label.name == 'panel-review' || github.event.label.name == 'needs-triage' || github.event_name == 'workflow_dispatch'", shouldHaveIf: true, }, { From e893ffe2058294ff344d39726af9a3759fd6f772 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 27 Apr 2026 14:32:53 +0000 Subject: [PATCH 04/10] =?UTF-8?q?rename:=20label-names=20=E2=86=92=20label?= =?UTF-8?q?s=20in=20on:=20frontmatter=20field?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Agent-Logs-Url: https://github.com/github/gh-aw/sessions/20a11ac1-45ec-45c4-ab0c-13c1281c364e Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> --- pkg/parser/schemas/main_workflow_schema.json | 2 +- pkg/workflow/compiler_pre_activation_job.go | 4 +-- pkg/workflow/compiler_types.go | 2 +- pkg/workflow/label_names_test.go | 28 ++++++++++---------- pkg/workflow/role_checks.go | 14 +++++----- 5 files changed, 25 insertions(+), 25 deletions(-) diff --git a/pkg/parser/schemas/main_workflow_schema.json b/pkg/parser/schemas/main_workflow_schema.json index 71c7a7c6c43..e82396d9ead 100644 --- a/pkg/parser/schemas/main_workflow_schema.json +++ b/pkg/parser/schemas/main_workflow_schema.json @@ -1828,7 +1828,7 @@ "description": "Bot identifier/name (e.g., 'dependabot[bot]', 'renovate[bot]', 'github-actions[bot]')" } }, - "label-names": { + "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 (\u29b8) rather than Failed (\u274c).", "oneOf": [ { diff --git a/pkg/workflow/compiler_pre_activation_job.go b/pkg/workflow/compiler_pre_activation_job.go index e765017121d..b4c69762d78 100644 --- a/pkg/workflow/compiler_pre_activation_job.go +++ b/pkg/workflow/compiler_pre_activation_job.go @@ -421,7 +421,7 @@ func (c *Compiler) buildPreActivationJob(data *WorkflowData, needsPermissionChec jobIfCondition = data.If } - // When label-names is specified, add a job-level if: condition to the pre-activation job. + // 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. @@ -456,7 +456,7 @@ func (c *Compiler) buildPreActivationJob(data *WorkflowData, needsPermissionChec return job, nil } -// buildLabelNamesCondition constructs the GitHub Actions if: expression for label-names filtering. +// buildLabelNamesCondition constructs the GitHub Actions if: expression for labels filtering. // The generated condition passes when: // - the triggering label name matches any of the specified names, OR // - the event is workflow_dispatch (so manual runs are never blocked). diff --git a/pkg/workflow/compiler_types.go b/pkg/workflow/compiler_types.go index f2c2ffcc72a..fae69d70988 100644 --- a/pkg/workflow/compiler_types.go +++ b/pkg/workflow/compiler_types.go @@ -472,7 +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.label-names) + 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 diff --git a/pkg/workflow/label_names_test.go b/pkg/workflow/label_names_test.go index 84a741b7b2e..54ad063370e 100644 --- a/pkg/workflow/label_names_test.go +++ b/pkg/workflow/label_names_test.go @@ -28,7 +28,7 @@ func TestExtractLabelNames(t *testing.T) { "pull_request_target": map[string]any{ "types": []any{"labeled"}, }, - "label-names": "panel-review", + "labels": "panel-review", }, }, expected: []string{"panel-review"}, @@ -40,13 +40,13 @@ func TestExtractLabelNames(t *testing.T) { "pull_request_target": map[string]any{ "types": []any{"labeled"}, }, - "label-names": []any{"panel-review", "needs-triage"}, + "labels": []any{"panel-review", "needs-triage"}, }, }, expected: []string{"panel-review", "needs-triage"}, }, { - name: "no label-names field returns nil", + name: "no labels field returns nil", frontmatter: map[string]any{ "on": map[string]any{ "pull_request_target": map[string]any{ @@ -97,11 +97,11 @@ func TestBuildLabelNamesCondition(t *testing.T) { } } -// TestLabelNamesPreActivationFilter verifies that on.label-names generates a job-level +// TestLabelNamesPreActivationFilter verifies that on.labels generates a job-level // if: condition on the pre_activation job that skips the workflow when the triggering // label does not match (gray ⊘ rather than red ❌). func TestLabelNamesPreActivationFilter(t *testing.T) { - tmpDir := testutil.TempDir(t, "label-names-filter-test") + tmpDir := testutil.TempDir(t, "labels-filter-test") compiler := NewCompiler() tests := []struct { @@ -111,12 +111,12 @@ func TestLabelNamesPreActivationFilter(t *testing.T) { shouldHaveIf bool }{ { - name: "pull_request_target with single label-names", + name: "pull_request_target with single labels", frontmatter: `--- on: pull_request_target: types: [labeled] - label-names: panel-review + labels: panel-review permissions: contents: read @@ -132,12 +132,12 @@ tools: shouldHaveIf: true, }, { - name: "pull_request_target with multiple label-names", + name: "pull_request_target with multiple labels", frontmatter: `--- on: pull_request_target: types: [labeled] - label-names: [panel-review, needs-triage] + labels: [panel-review, needs-triage] permissions: contents: read @@ -153,7 +153,7 @@ tools: shouldHaveIf: true, }, { - name: "pull_request_target without label-names has no if condition from label filter", + name: "pull_request_target without labels has no if condition from label filter", frontmatter: `--- on: pull_request_target: @@ -173,12 +173,12 @@ tools: shouldHaveIf: false, }, { - name: "issues with label-names generates pre-activation if condition", + name: "issues with labels generates pre-activation if condition", frontmatter: `--- on: issues: types: [labeled] - label-names: [bug, enhancement] + labels: [bug, enhancement] permissions: contents: read @@ -198,7 +198,7 @@ tools: for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { testFile := tmpDir + "/test-" + strings.ReplaceAll(tt.name, " ", "-") + ".md" - content := tt.frontmatter + "\n\n# Test Workflow\n\nTest label-names filter." + content := tt.frontmatter + "\n\n# Test Workflow\n\nTest labels filter." require.NoError(t, os.WriteFile(testFile, []byte(content), 0644), "should write test file") err := compiler.CompileWorkflow(testFile) @@ -218,7 +218,7 @@ tools: "pre_activation job should have if condition matching label filter") } else { assert.NotContains(t, lockContent, tt.expectedIf, - "pre_activation job should not have label-name if condition when label-names not specified") + "pre_activation job should not have label-name if condition when labels not specified") } }) } diff --git a/pkg/workflow/role_checks.go b/pkg/workflow/role_checks.go index 4c174d6c617..0684c6bff38 100644 --- a/pkg/workflow/role_checks.go +++ b/pkg/workflow/role_checks.go @@ -91,16 +91,16 @@ func (c *Compiler) generateRateLimitCheck(data *WorkflowData, steps []string) [] return steps } -// extractLabelNames extracts the 'label-names' field from frontmatter. +// extractLabelNames extracts the 'labels' field from frontmatter. // When set, the pre-activation job emits a job-level if: condition that skips the workflow // (gray ⊘ rather than red ❌) when the triggering label does not match. func (c *Compiler) extractLabelNames(frontmatter map[string]any) []string { if onValue, exists := frontmatter["on"]; exists { if onMap, ok := onValue.(map[string]any); ok { - if labelNamesValue, hasLabelNames := onMap["label-names"]; hasLabelNames { - names := parseOptionalStringSliceField(labelNamesValue, "on.label-names") + if labelNamesValue, hasLabelNames := onMap["labels"]; hasLabelNames { + names := parseOptionalStringSliceField(labelNamesValue, "on.labels") if len(names) > 0 { - roleLog.Printf("Extracted %d label-names: %v", len(names), names) + roleLog.Printf("Extracted %d labels: %v", len(names), names) return names } } @@ -387,8 +387,8 @@ func (c *Compiler) hasSafeEventsOnly(data *WorkflowData, frontmatter map[string] for eventName := range onMap { // Skip command events as they are handled separately // Skip stop-after and reaction as they are not event types - // Skip roles, bots, and label-names as they are configuration, not event types - if eventName == "command" || eventName == "stop-after" || eventName == "reaction" || eventName == "roles" || eventName == "bots" || eventName == "label-names" { + // Skip roles, bots, and labels as they are configuration, not event types + if eventName == "command" || eventName == "stop-after" || eventName == "reaction" || eventName == "roles" || eventName == "bots" || eventName == "labels" { continue } @@ -426,7 +426,7 @@ func (c *Compiler) hasSafeEventsOnly(data *WorkflowData, frontmatter map[string] if _, hasBots := onMap["bots"]; hasBots { eventCount-- } - if _, hasLabelNames := onMap["label-names"]; hasLabelNames { + if _, hasLabelNames := onMap["labels"]; hasLabelNames { eventCount-- } From 26f59a46844995b6796fdfeb7434f692a7bb7857 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 27 Apr 2026 14:39:02 +0000 Subject: [PATCH 05/10] docs: rename label-names to labels in frontmatter-full.md Agent-Logs-Url: https://github.com/github/gh-aw/sessions/89fc8f24-b129-4a19-ba12-8bad7c697df9 Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> --- docs/src/content/docs/reference/frontmatter-full.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/src/content/docs/reference/frontmatter-full.md b/docs/src/content/docs/reference/frontmatter-full.md index 8ad92cbbaf3..c9d03fb2ee2 100644 --- a/docs/src/content/docs/reference/frontmatter-full.md +++ b/docs/src/content/docs/reference/frontmatter-full.md @@ -825,11 +825,11 @@ on: # Option 1: Single label name that must match the triggering label (e.g., # 'panel-review') - label-names: "example-value" + labels: "example-value" # Option 2: List of label names; the workflow fires when the triggering label # matches any entry. - label-names: [] + labels: [] # Array items: Label name (e.g., 'panel-review', 'needs-triage') # Environment name that requires manual approval before the workflow can run. Must From f6f919c40c31cd57d83336e95c630ac5dd2dd1dc Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 27 Apr 2026 15:08:04 +0000 Subject: [PATCH 06/10] fix: labels filter passes through events without label data; add trigger_label to aw_context Agent-Logs-Url: https://github.com/github/gh-aw/sessions/4db8d600-2c63-49c9-b93e-906ce675b69c Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> --- actions/setup/js/aw_context.cjs | 10 +++++- pkg/workflow/compiler_pre_activation_job.go | 37 +++++++++------------ pkg/workflow/label_names_test.go | 10 +++--- 3 files changed, 30 insertions(+), 27 deletions(-) diff --git a/actions/setup/js/aw_context.cjs b/actions/setup/js/aw_context.cjs index 0b223056571..f4a73e0cda3 100644 --- a/actions/setup/js/aw_context.cjs +++ b/actions/setup/js/aw_context.cjs @@ -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, @@ -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); @@ -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 ?? "", }; } diff --git a/pkg/workflow/compiler_pre_activation_job.go b/pkg/workflow/compiler_pre_activation_job.go index b4c69762d78..43d3ac7a878 100644 --- a/pkg/workflow/compiler_pre_activation_job.go +++ b/pkg/workflow/compiler_pre_activation_job.go @@ -458,34 +458,29 @@ func (c *Compiler) buildPreActivationJob(data *WorkflowData, needsPermissionChec // buildLabelNamesCondition constructs the GitHub Actions if: expression for labels filtering. // The generated condition passes when: -// - the triggering label name matches any of the specified names, OR -// - the event is workflow_dispatch (so manual runs are never blocked). +// - the event has no label data (github.event.label.name is empty, which covers +// workflow_dispatch, push, schedule, and any other non-labeled events), OR +// - the triggering label name matches any of the specified names. +// +// This ensures the filter only acts on events that actually carry label information, +// leaving all other events (e.g. workflow_dispatch, push) unaffected. func buildLabelNamesCondition(labelNames []string) string { - var labelChecks []ConditionNode + // Pass through events without label data: github.event.label.name is empty + // for workflow_dispatch, push, and any event that does not carry a label payload. + noLabelEvent := BuildEquals( + BuildPropertyAccess("github.event.label.name"), + BuildStringLiteral(""), + ) + + result := ConditionNode(noLabelEvent) for _, name := range labelNames { - labelChecks = append(labelChecks, BuildEquals( + result = BuildOr(result, BuildEquals( BuildPropertyAccess("github.event.label.name"), BuildStringLiteral(name), )) } - var labelMatch ConditionNode - if len(labelChecks) == 1 { - labelMatch = labelChecks[0] - } else { - labelMatch = labelChecks[0] - for i := 1; i < len(labelChecks); i++ { - labelMatch = BuildOr(labelMatch, labelChecks[i]) - } - } - - // Always allow workflow_dispatch so manual runs are not blocked by the label filter. - workflowDispatch := BuildEquals( - BuildPropertyAccess("github.event_name"), - BuildStringLiteral("workflow_dispatch"), - ) - - return BuildOr(labelMatch, workflowDispatch).Render() + return result.Render() } // generateReportSkipStep generates the "Report skip reason" step for the pre-activation job. diff --git a/pkg/workflow/label_names_test.go b/pkg/workflow/label_names_test.go index 54ad063370e..39227cf7a79 100644 --- a/pkg/workflow/label_names_test.go +++ b/pkg/workflow/label_names_test.go @@ -80,12 +80,12 @@ func TestBuildLabelNamesCondition(t *testing.T) { { name: "single label name", labelNames: []string{"panel-review"}, - expected: "github.event.label.name == 'panel-review' || github.event_name == 'workflow_dispatch'", + expected: "github.event.label.name == '' || github.event.label.name == 'panel-review'", }, { name: "multiple label names", labelNames: []string{"panel-review", "needs-triage"}, - expected: "github.event.label.name == 'panel-review' || github.event.label.name == 'needs-triage' || github.event_name == 'workflow_dispatch'", + expected: "github.event.label.name == '' || github.event.label.name == 'panel-review' || github.event.label.name == 'needs-triage'", }, } @@ -128,7 +128,7 @@ tools: github: allowed: [get_pull_request] ---`, - expectedIf: "github.event.label.name == 'panel-review' || github.event_name == 'workflow_dispatch'", + expectedIf: "github.event.label.name == '' || github.event.label.name == 'panel-review'", shouldHaveIf: true, }, { @@ -149,7 +149,7 @@ tools: github: allowed: [get_pull_request] ---`, - expectedIf: "github.event.label.name == 'panel-review' || github.event.label.name == 'needs-triage' || github.event_name == 'workflow_dispatch'", + expectedIf: "github.event.label.name == '' || github.event.label.name == 'panel-review' || github.event.label.name == 'needs-triage'", shouldHaveIf: true, }, { @@ -190,7 +190,7 @@ tools: github: allowed: [issue_read] ---`, - expectedIf: "github.event.label.name == 'bug' || github.event.label.name == 'enhancement' || github.event_name == 'workflow_dispatch'", + expectedIf: "github.event.label.name == '' || github.event.label.name == 'bug' || github.event.label.name == 'enhancement'", shouldHaveIf: true, }, } From 45204eaef5029018e35a41cd5e6c3a369a93a0d3 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Mon, 27 Apr 2026 16:17:47 +0000 Subject: [PATCH 07/10] docs(adr): add draft ADR-28737 for first-class labels filter MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Generated by Design Decision Gate — author must review and finalize before the PR can merge. Co-Authored-By: Claude Sonnet 4.6 --- ...-class-labels-filter-for-labeled-events.md | 84 +++++++++++++++++++ 1 file changed, 84 insertions(+) create mode 100644 docs/adr/28737-first-class-labels-filter-for-labeled-events.md diff --git a/docs/adr/28737-first-class-labels-filter-for-labeled-events.md b/docs/adr/28737-first-class-labels-filter-for-labeled-events.md new file mode 100644 index 00000000000..9fd95da96a9 --- /dev/null +++ b/docs/adr/28737-first-class-labels-filter-for-labeled-events.md @@ -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.* From e57b589ee34c6fd8c56408896db41f9b64c59794 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 27 Apr 2026 16:30:52 +0000 Subject: [PATCH 08/10] =?UTF-8?q?fix:=20address=20review=20comments=20?= =?UTF-8?q?=E2=80=94=20null=20guard,=20pre=5Factivation=20creation,=20quot?= =?UTF-8?q?e=20escaping,=20symbol,=20test?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Agent-Logs-Url: https://github.com/github/gh-aw/sessions/08916acf-6edb-4176-925a-c95f33a82f6e Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> --- .../docs/reference/frontmatter-full.md | 2 +- pkg/parser/schemas/main_workflow_schema.json | 2 +- pkg/workflow/compiler_jobs.go | 7 +++--- pkg/workflow/compiler_pre_activation_job.go | 25 ++++++++++--------- pkg/workflow/expression_nodes.go | 4 ++- pkg/workflow/expressions_test.go | 10 ++++++++ pkg/workflow/label_names_test.go | 17 ++++++++----- 7 files changed, 43 insertions(+), 24 deletions(-) diff --git a/docs/src/content/docs/reference/frontmatter-full.md b/docs/src/content/docs/reference/frontmatter-full.md index c9d03fb2ee2..a7f94bc06ff 100644 --- a/docs/src/content/docs/reference/frontmatter-full.md +++ b/docs/src/content/docs/reference/frontmatter-full.md @@ -819,7 +819,7 @@ on: # 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 (❌). + # as Skipped (⊘) rather than Failed (❌). # (optional) # This field supports multiple formats (oneOf): diff --git a/pkg/parser/schemas/main_workflow_schema.json b/pkg/parser/schemas/main_workflow_schema.json index e82396d9ead..26d1ef87f07 100644 --- a/pkg/parser/schemas/main_workflow_schema.json +++ b/pkg/parser/schemas/main_workflow_schema.json @@ -1829,7 +1829,7 @@ } }, "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 (\u29b8) rather than Failed (\u274c).", + "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": [ { "type": "string", diff --git a/pkg/workflow/compiler_jobs.go b/pkg/workflow/compiler_jobs.go index 8c3e50d9982..71c1320d762 100644 --- a/pkg/workflow/compiler_jobs.go +++ b/pkg/workflow/compiler_jobs.go @@ -268,10 +268,11 @@ 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) + 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 (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 { + // 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, on.steps injection, and 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 { diff --git a/pkg/workflow/compiler_pre_activation_job.go b/pkg/workflow/compiler_pre_activation_job.go index 43d3ac7a878..021e310cb95 100644 --- a/pkg/workflow/compiler_pre_activation_job.go +++ b/pkg/workflow/compiler_pre_activation_job.go @@ -458,21 +458,22 @@ func (c *Compiler) buildPreActivationJob(data *WorkflowData, needsPermissionChec // buildLabelNamesCondition constructs the GitHub Actions if: expression for labels filtering. // The generated condition passes when: -// - the event has no label data (github.event.label.name is empty, which covers -// workflow_dispatch, push, schedule, and any other non-labeled events), OR +// - 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. // -// This ensures the filter only acts on events that actually carry label information, -// leaving all other events (e.g. workflow_dispatch, push) unaffected. +// 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 label data: github.event.label.name is empty - // for workflow_dispatch, push, and any event that does not carry a label payload. - noLabelEvent := BuildEquals( - BuildPropertyAccess("github.event.label.name"), - BuildStringLiteral(""), - ) - - result := ConditionNode(noLabelEvent) + // 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"), diff --git a/pkg/workflow/expression_nodes.go b/pkg/workflow/expression_nodes.go index cbf5b916a97..1c1c7c9f0c7 100644 --- a/pkg/workflow/expression_nodes.go +++ b/pkg/workflow/expression_nodes.go @@ -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 diff --git a/pkg/workflow/expressions_test.go b/pkg/workflow/expressions_test.go index 3fb55d26ba4..a6581a336c9 100644 --- a/pkg/workflow/expressions_test.go +++ b/pkg/workflow/expressions_test.go @@ -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 { diff --git a/pkg/workflow/label_names_test.go b/pkg/workflow/label_names_test.go index 39227cf7a79..704de5993c4 100644 --- a/pkg/workflow/label_names_test.go +++ b/pkg/workflow/label_names_test.go @@ -80,12 +80,17 @@ func TestBuildLabelNamesCondition(t *testing.T) { { name: "single label name", labelNames: []string{"panel-review"}, - expected: "github.event.label.name == '' || github.event.label.name == 'panel-review'", + expected: "github.event.label == null || github.event.label.name == 'panel-review'", }, { name: "multiple label names", labelNames: []string{"panel-review", "needs-triage"}, - expected: "github.event.label.name == '' || github.event.label.name == 'panel-review' || github.event.label.name == 'needs-triage'", + expected: "github.event.label == null || github.event.label.name == 'panel-review' || github.event.label.name == 'needs-triage'", + }, + { + name: "label name with single quote", + labelNames: []string{"can't-repro"}, + expected: "github.event.label == null || github.event.label.name == 'can''t-repro'", }, } @@ -128,7 +133,7 @@ tools: github: allowed: [get_pull_request] ---`, - expectedIf: "github.event.label.name == '' || github.event.label.name == 'panel-review'", + expectedIf: "github.event.label == null || github.event.label.name == 'panel-review'", shouldHaveIf: true, }, { @@ -149,7 +154,7 @@ tools: github: allowed: [get_pull_request] ---`, - expectedIf: "github.event.label.name == '' || github.event.label.name == 'panel-review' || github.event.label.name == 'needs-triage'", + expectedIf: "github.event.label == null || github.event.label.name == 'panel-review' || github.event.label.name == 'needs-triage'", shouldHaveIf: true, }, { @@ -169,7 +174,7 @@ tools: github: allowed: [get_pull_request] ---`, - expectedIf: "github.event.label.name", + expectedIf: "github.event.label == null", shouldHaveIf: false, }, { @@ -190,7 +195,7 @@ tools: github: allowed: [issue_read] ---`, - expectedIf: "github.event.label.name == '' || github.event.label.name == 'bug' || github.event.label.name == 'enhancement'", + expectedIf: "github.event.label == null || github.event.label.name == 'bug' || github.event.label.name == 'enhancement'", shouldHaveIf: true, }, } From 109e7dd895f59321b2ede1ddf1d05160ff01a8fc Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 27 Apr 2026 16:32:51 +0000 Subject: [PATCH 09/10] fix: improve comment clarity and test naming per review feedback Agent-Logs-Url: https://github.com/github/gh-aw/sessions/08916acf-6edb-4176-925a-c95f33a82f6e Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> --- pkg/workflow/compiler_jobs.go | 5 ++++- pkg/workflow/label_names_test.go | 4 +++- 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/pkg/workflow/compiler_jobs.go b/pkg/workflow/compiler_jobs.go index 71c1320d762..405de609b39 100644 --- a/pkg/workflow/compiler_jobs.go +++ b/pkg/workflow/compiler_jobs.go @@ -271,7 +271,10 @@ func (c *Compiler) buildPreActivationAndActivationJobs(data *WorkflowData, front 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 (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, on.steps injection, and label-names filter) + // 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) diff --git a/pkg/workflow/label_names_test.go b/pkg/workflow/label_names_test.go index 704de5993c4..b1ebadf977a 100644 --- a/pkg/workflow/label_names_test.go +++ b/pkg/workflow/label_names_test.go @@ -158,7 +158,9 @@ tools: shouldHaveIf: true, }, { - name: "pull_request_target without labels has no if condition from label filter", + // Negative test: no on.labels specified → the label-filter condition should not appear. + // expectedIf is set to a substring of the filter expression to confirm its absence. + name: "pull_request_target without labels has no label-filter if condition", frontmatter: `--- on: pull_request_target: From 0a70bdc2f0a0294bb83a0e466618f5a3e067f96e Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 27 Apr 2026 17:26:05 +0000 Subject: [PATCH 10/10] fix(schema): add non_empty_string $def and use it for on.labels to prevent empty label names Agent-Logs-Url: https://github.com/github/gh-aw/sessions/afdedf8f-08bb-4c0e-aa2b-d9613130ff93 Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> --- pkg/parser/schemas/main_workflow_schema.json | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/pkg/parser/schemas/main_workflow_schema.json b/pkg/parser/schemas/main_workflow_schema.json index 26d1ef87f07..df35474f71a 100644 --- a/pkg/parser/schemas/main_workflow_schema.json +++ b/pkg/parser/schemas/main_workflow_schema.json @@ -1832,17 +1832,14 @@ "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": [ { - "type": "string", - "minLength": 1, + "$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": { - "type": "string", - "minLength": 1, - "description": "Label name (e.g., 'panel-review', 'needs-triage')" + "$ref": "#/$defs/non_empty_string" }, "minItems": 1, "maxItems": 50 @@ -9356,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": [