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/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.* diff --git a/docs/src/content/docs/reference/frontmatter-full.md b/docs/src/content/docs/reference/frontmatter-full.md index 3d056cd54bd..a7f94bc06ff 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') + 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) diff --git a/pkg/parser/schemas/main_workflow_schema.json b/pkg/parser/schemas/main_workflow_schema.json index 060101e3e13..df35474f71a 100644 --- a/pkg/parser/schemas/main_workflow_schema.json +++ b/pkg/parser/schemas/main_workflow_schema.json @@ -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." @@ -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": [ diff --git a/pkg/workflow/compiler_jobs.go b/pkg/workflow/compiler_jobs.go index 8c3e50d9982..405de609b39 100644 --- a/pkg/workflow/compiler_jobs.go +++ b/pkg/workflow/compiler_jobs.go @@ -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 { 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..021e310cb95 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 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 + } + } + // 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,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), + )) + } + + 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. diff --git a/pkg/workflow/compiler_types.go b/pkg/workflow/compiler_types.go index 267db0c76d0..b46934438ef 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.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/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 new file mode 100644 index 00000000000..b1ebadf977a --- /dev/null +++ b/pkg/workflow/label_names_test.go @@ -0,0 +1,232 @@ +//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"}, + }, + "labels": "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"}, + }, + "labels": []any{"panel-review", "needs-triage"}, + }, + }, + expected: []string{"panel-review", "needs-triage"}, + }, + { + name: "no labels 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 == null || github.event.label.name == 'panel-review'", + }, + { + name: "multiple label names", + labelNames: []string{"panel-review", "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'", + }, + } + + 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.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, "labels-filter-test") + compiler := NewCompiler() + + tests := []struct { + name string + frontmatter string + expectedIf string + shouldHaveIf bool + }{ + { + name: "pull_request_target with single labels", + frontmatter: `--- +on: + pull_request_target: + types: [labeled] + labels: panel-review + +permissions: + contents: read + pull-requests: read + issues: read + +strict: false +tools: + github: + allowed: [get_pull_request] +---`, + expectedIf: "github.event.label == null || github.event.label.name == 'panel-review'", + shouldHaveIf: true, + }, + { + name: "pull_request_target with multiple labels", + frontmatter: `--- +on: + pull_request_target: + types: [labeled] + labels: [panel-review, needs-triage] + +permissions: + contents: read + pull-requests: read + issues: read + +strict: false +tools: + github: + allowed: [get_pull_request] +---`, + expectedIf: "github.event.label == null || github.event.label.name == 'panel-review' || github.event.label.name == 'needs-triage'", + shouldHaveIf: true, + }, + { + // 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: + types: [labeled] + +permissions: + contents: read + pull-requests: read + issues: read + +strict: false +tools: + github: + allowed: [get_pull_request] +---`, + expectedIf: "github.event.label == null", + shouldHaveIf: false, + }, + { + name: "issues with labels generates pre-activation if condition", + frontmatter: `--- +on: + issues: + types: [labeled] + labels: [bug, enhancement] + +permissions: + contents: read + issues: read + pull-requests: read + +strict: false +tools: + github: + allowed: [issue_read] +---`, + expectedIf: "github.event.label == null || github.event.label.name == 'bug' || github.event.label.name == 'enhancement'", + 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 labels 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 labels not specified") + } + }) + } +} diff --git a/pkg/workflow/role_checks.go b/pkg/workflow/role_checks.go index 28761bb3550..0684c6bff38 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 '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["labels"]; hasLabelNames { + names := parseOptionalStringSliceField(labelNamesValue, "on.labels") + if len(names) > 0 { + roleLog.Printf("Extracted %d labels: %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 labels as they are configuration, not event types + if eventName == "command" || eventName == "stop-after" || eventName == "reaction" || eventName == "roles" || eventName == "bots" || eventName == "labels" { continue } @@ -408,6 +426,9 @@ func (c *Compiler) hasSafeEventsOnly(data *WorkflowData, frontmatter map[string] if _, hasBots := onMap["bots"]; hasBots { eventCount-- } + if _, hasLabelNames := onMap["labels"]; hasLabelNames { + eventCount-- + } // Special handling for workflow_dispatch: // workflow_dispatch can be triggered by users with "write" access,