From a1689eff15885e173e549ad97c1f0edd364aec3e Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 15 Mar 2026 20:15:24 +0000 Subject: [PATCH 01/11] Initial plan From 1039de5a460736c5dd9719a407ed42f3222ce28a Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 15 Mar 2026 20:49:08 +0000 Subject: [PATCH 02/11] feat: add label-command trigger support (On Label Command) Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> --- actions/setup/js/remove_trigger_label.cjs | 128 ++++++ pkg/constants/constants.go | 1 + pkg/parser/schemas/main_workflow_schema.json | 80 ++++ pkg/workflow/compiler_activation_job.go | 22 + .../compiler_orchestrator_workflow.go | 1 + pkg/workflow/compiler_safe_outputs.go | 32 +- pkg/workflow/compiler_types.go | 3 + pkg/workflow/frontmatter_extraction_yaml.go | 67 +++ pkg/workflow/label_command.go | 95 ++++ pkg/workflow/label_command_parser.go | 19 + pkg/workflow/label_command_test.go | 408 ++++++++++++++++++ pkg/workflow/schedule_preprocessing.go | 12 + pkg/workflow/tools.go | 66 ++- 13 files changed, 931 insertions(+), 3 deletions(-) create mode 100644 actions/setup/js/remove_trigger_label.cjs create mode 100644 pkg/workflow/label_command.go create mode 100644 pkg/workflow/label_command_parser.go create mode 100644 pkg/workflow/label_command_test.go diff --git a/actions/setup/js/remove_trigger_label.cjs b/actions/setup/js/remove_trigger_label.cjs new file mode 100644 index 0000000000..047ade88e4 --- /dev/null +++ b/actions/setup/js/remove_trigger_label.cjs @@ -0,0 +1,128 @@ +// @ts-check +/// + +const { ERR_API, ERR_CONFIG } = require("./error_codes.cjs"); + +/** + * Remove the label that triggered this workflow from the issue, pull request, or discussion. + * This allows the same label to be applied again later to re-trigger the workflow. + * + * Supported events: issues (labeled), pull_request (labeled), discussion (labeled). + * For workflow_dispatch, the step emits an empty label_name output and exits without error. + */ +async function main() { + const labelNamesJSON = process.env.GH_AW_LABEL_NAMES; + + const { getErrorMessage } = require("./error_helpers.cjs"); + + if (!labelNamesJSON) { + core.setFailed(`${ERR_CONFIG}: Configuration error: GH_AW_LABEL_NAMES not specified.`); + return; + } + + let labelNames = []; + try { + labelNames = JSON.parse(labelNamesJSON); + if (!Array.isArray(labelNames)) { + core.setFailed(`${ERR_CONFIG}: Configuration error: GH_AW_LABEL_NAMES must be a JSON array.`); + return; + } + } catch (error) { + core.setFailed(`${ERR_CONFIG}: Configuration error: Failed to parse GH_AW_LABEL_NAMES: ${getErrorMessage(error)}`); + return; + } + + const eventName = context.eventName; + + // For workflow_dispatch and other non-labeled events, nothing to remove. + if (eventName === "workflow_dispatch") { + core.info("Event is workflow_dispatch – skipping label removal."); + core.setOutput("label_name", ""); + return; + } + + // Retrieve the label that was added from the event payload. + const triggerLabel = context.payload?.label?.name; + if (!triggerLabel) { + core.info(`Event ${eventName} has no label payload – skipping label removal.`); + core.setOutput("label_name", ""); + return; + } + + // Confirm that this label is one of the configured command labels. + if (!labelNames.includes(triggerLabel)) { + core.info(`Trigger label '${triggerLabel}' is not in the configured label-command list [${labelNames.join(", ")}] – skipping removal.`); + core.setOutput("label_name", triggerLabel); + return; + } + + core.info(`Removing trigger label '${triggerLabel}' (event: ${eventName})`); + + const [owner, repo] = context.repo ? [context.repo.owner, context.repo.repo] : (context.payload?.repository?.full_name ?? "/").split("/"); + + try { + if (eventName === "issues") { + const issueNumber = context.payload?.issue?.number; + if (!issueNumber) { + core.warning("No issue number found in payload – skipping label removal."); + core.setOutput("label_name", triggerLabel); + return; + } + await github.rest.issues.removeLabel({ + owner, + repo, + issue_number: issueNumber, + name: triggerLabel, + }); + core.info(`✓ Removed label '${triggerLabel}' from issue #${issueNumber}`); + } else if (eventName === "pull_request") { + // Pull requests share the issues API for labels. + const prNumber = context.payload?.pull_request?.number; + if (!prNumber) { + core.warning("No pull request number found in payload – skipping label removal."); + core.setOutput("label_name", triggerLabel); + return; + } + await github.rest.issues.removeLabel({ + owner, + repo, + issue_number: prNumber, + name: triggerLabel, + }); + core.info(`✓ Removed label '${triggerLabel}' from pull request #${prNumber}`); + } else if (eventName === "discussion") { + // Discussions require the GraphQL API for label management. + const discussionNodeId = context.payload?.discussion?.node_id; + const labelNodeId = context.payload?.label?.node_id; + if (!discussionNodeId || !labelNodeId) { + core.warning("No discussion or label node_id found in payload – skipping label removal."); + core.setOutput("label_name", triggerLabel); + return; + } + await github.graphql( + ` + mutation RemoveLabelFromDiscussion($labelableId: ID!, $labelIds: [ID!]!) { + removeLabelsFromLabelable(input: { labelableId: $labelableId, labelIds: $labelIds }) { + clientMutationId + } + } + `, + { + labelableId: discussionNodeId, + labelIds: [labelNodeId], + } + ); + core.info(`✓ Removed label '${triggerLabel}' from discussion`); + } else { + core.info(`Event '${eventName}' does not support label removal – skipping.`); + } + } catch (error) { + // Non-fatal: log a warning but do not fail the step. + // The label may have already been removed or may not be present. + core.warning(`${ERR_API}: Failed to remove label '${triggerLabel}': ${getErrorMessage(error)}`); + } + + core.setOutput("label_name", triggerLabel); +} + +module.exports = { main }; diff --git a/pkg/constants/constants.go b/pkg/constants/constants.go index 21d35a520e..304cf603ed 100644 --- a/pkg/constants/constants.go +++ b/pkg/constants/constants.go @@ -672,6 +672,7 @@ const CheckStopTimeStepID StepID = "check_stop_time" const CheckSkipIfMatchStepID StepID = "check_skip_if_match" const CheckSkipIfNoMatchStepID StepID = "check_skip_if_no_match" const CheckCommandPositionStepID StepID = "check_command_position" +const RemoveTriggerLabelStepID StepID = "remove_trigger_label" const CheckRateLimitStepID StepID = "check_rate_limit" const CheckSkipRolesStepID StepID = "check_skip_roles" const CheckSkipBotsStepID StepID = "check_skip_bots" diff --git a/pkg/parser/schemas/main_workflow_schema.json b/pkg/parser/schemas/main_workflow_schema.json index a8e81ee955..c89b9e4b3c 100644 --- a/pkg/parser/schemas/main_workflow_schema.json +++ b/pkg/parser/schemas/main_workflow_schema.json @@ -300,6 +300,86 @@ } ] }, + "label_command": { + "description": "On Label Command trigger: fires when a specific label is added to an issue, pull request, or discussion. The triggering label is automatically removed at workflow start so it can be applied again to re-trigger. Use the 'events' field to restrict which item types (issues, pull_request, discussion) activate the trigger.", + "oneOf": [ + { + "type": "string", + "minLength": 1, + "description": "Label name as a string (shorthand format). The workflow fires when this label is added to any supported item type (issue, pull request, or discussion)." + }, + { + "type": "object", + "description": "Label command configuration object with label name(s) and optional event filtering.", + "properties": { + "name": { + "oneOf": [ + { + "type": "string", + "minLength": 1, + "description": "Single label name that acts as a command (e.g., 'deploy' triggers the workflow when the 'deploy' label is added)." + }, + { + "type": "array", + "minItems": 1, + "description": "Array of label names — any of these labels will trigger the workflow.", + "items": { + "type": "string", + "minLength": 1, + "description": "A label name" + }, + "maxItems": 25 + } + ], + "description": "Label name(s) that trigger the workflow when added to an issue, pull request, or discussion." + }, + "names": { + "oneOf": [ + { + "type": "string", + "minLength": 1, + "description": "Single label name." + }, + { + "type": "array", + "minItems": 1, + "description": "Array of label names — any of these labels will trigger the workflow.", + "items": { + "type": "string", + "minLength": 1, + "description": "A label name" + }, + "maxItems": 25 + } + ], + "description": "Alternative to 'name': label name(s) that trigger the workflow." + }, + "events": { + "description": "Item types where the label-command trigger should be active. Default is all supported types: issues, pull_request, discussion.", + "oneOf": [ + { + "type": "string", + "description": "Single item type or '*' for all types.", + "enum": ["*", "issues", "pull_request", "discussion"] + }, + { + "type": "array", + "minItems": 1, + "description": "Array of item types where the trigger is active.", + "items": { + "type": "string", + "description": "Item type.", + "enum": ["*", "issues", "pull_request", "discussion"] + }, + "maxItems": 3 + } + ] + } + }, + "additionalProperties": false + } + ] + }, "push": { "description": "Push event trigger that runs the workflow when code is pushed to the repository", "type": "object", diff --git a/pkg/workflow/compiler_activation_job.go b/pkg/workflow/compiler_activation_job.go index 95df0b548c..004c8c4d0f 100644 --- a/pkg/workflow/compiler_activation_job.go +++ b/pkg/workflow/compiler_activation_job.go @@ -1,6 +1,7 @@ package workflow import ( + "encoding/json" "errors" "fmt" "strings" @@ -284,6 +285,27 @@ func (c *Compiler) buildActivationJob(data *WorkflowData, preActivationJobCreate } } + // Add label removal step and label_command output for label-command workflows. + // When a label-command trigger fires, the triggering label is immediately removed + // so that the same label can be applied again to trigger the workflow in the future. + if len(data.LabelCommand) > 0 { + // The removal step only makes sense for actual "labeled" events; for + // workflow_dispatch we skip it silently via the env-based label check. + steps = append(steps, " - name: Remove trigger label\n") + steps = append(steps, fmt.Sprintf(" id: %s\n", constants.RemoveTriggerLabelStepID)) + steps = append(steps, fmt.Sprintf(" uses: %s\n", GetActionPin("actions/github-script"))) + steps = append(steps, " env:\n") + // Pass label names as a JSON array so the script can validate the label + labelNamesJSON, _ := json.Marshal(data.LabelCommand) + steps = append(steps, fmt.Sprintf(" GH_AW_LABEL_NAMES: %q\n", string(labelNamesJSON))) + steps = append(steps, " with:\n") + steps = append(steps, " script: |\n") + steps = append(steps, generateGitHubScriptWithRequire("remove_trigger_label.cjs")) + + // Expose the matched label name as a job output for downstream jobs to consume + outputs["label_command"] = fmt.Sprintf("${{ steps.%s.outputs.label_name }}", constants.RemoveTriggerLabelStepID) + } + // If no steps have been added, add a placeholder step to make the job valid // This can happen when the activation job is created only for an if condition if len(steps) == 0 { diff --git a/pkg/workflow/compiler_orchestrator_workflow.go b/pkg/workflow/compiler_orchestrator_workflow.go index 23ac893f85..8799258d91 100644 --- a/pkg/workflow/compiler_orchestrator_workflow.go +++ b/pkg/workflow/compiler_orchestrator_workflow.go @@ -596,6 +596,7 @@ func (c *Compiler) extractAdditionalConfigurations( // Extract and process mcp-scripts and safe-outputs workflowData.Command, workflowData.CommandEvents = c.extractCommandConfig(frontmatter) + workflowData.LabelCommand, workflowData.LabelCommandEvents = c.extractLabelCommandConfig(frontmatter) workflowData.Jobs = c.extractJobsFromFrontmatter(frontmatter) // Merge jobs from imported YAML workflows diff --git a/pkg/workflow/compiler_safe_outputs.go b/pkg/workflow/compiler_safe_outputs.go index eea5c8502d..6e49a13256 100644 --- a/pkg/workflow/compiler_safe_outputs.go +++ b/pkg/workflow/compiler_safe_outputs.go @@ -22,6 +22,7 @@ func (c *Compiler) parseOnSection(frontmatter map[string]any, workflowData *Work // Check if "slash_command" or "command" (deprecated) is used as a trigger in the "on" section // Also extract "reaction" from the "on" section var hasCommand bool + var hasLabelCommand bool var hasReaction bool var hasStopAfter bool var hasStatusComment bool @@ -139,8 +140,25 @@ func (c *Compiler) parseOnSection(frontmatter map[string]any, workflowData *Work // Clear the On field so applyDefaults will handle command trigger generation workflowData.On = "" } - // Extract other (non-conflicting) events excluding slash_command, command, reaction, status-comment, and stop-after - otherEvents = filterMapKeys(onMap, "slash_command", "command", "reaction", "status-comment", "stop-after", "github-token", "github-app") + + // Detect label_command trigger + if _, hasLabelCommandKey := onMap["label_command"]; hasLabelCommandKey { + hasLabelCommand = true + // Set default label names from WorkflowData if already populated by extractLabelCommandConfig + if len(workflowData.LabelCommand) == 0 { + // extractLabelCommandConfig has not been called yet or returned nothing; + // set a placeholder so applyDefaults knows this is a label-command workflow. + // The actual label names will be extracted from the frontmatter in applyDefaults + // via extractLabelCommandConfig which was called in parseOnSectionRaw. + baseName := strings.TrimSuffix(filepath.Base(markdownPath), ".md") + workflowData.LabelCommand = []string{baseName} + } + // Clear the On field so applyDefaults will handle label-command trigger generation + workflowData.On = "" + } + + // Extract other (non-conflicting) events excluding slash_command, command, label_command, reaction, status-comment, and stop-after + otherEvents = filterMapKeys(onMap, "slash_command", "command", "label_command", "reaction", "status-comment", "stop-after", "github-token", "github-app") } } @@ -149,6 +167,12 @@ func (c *Compiler) parseOnSection(frontmatter map[string]any, workflowData *Work workflowData.Command = nil } + // Clear label-command field if no label_command trigger was found + if !hasLabelCommand { + workflowData.LabelCommand = nil + workflowData.LabelCommandEvents = nil + } + // Auto-enable "eyes" reaction for command triggers if no explicit reaction was specified if hasCommand && !hasReaction && workflowData.AIReaction == "" { workflowData.AIReaction = "eyes" @@ -159,6 +183,10 @@ func (c *Compiler) parseOnSection(frontmatter map[string]any, workflowData *Work // We'll store this and handle it in applyDefaults workflowData.On = "" // This will trigger command handling in applyDefaults workflowData.CommandOtherEvents = otherEvents + } else if hasLabelCommand && len(otherEvents) > 0 { + // Store other events for label-command merging in applyDefaults + workflowData.On = "" // This will trigger label-command handling in applyDefaults + workflowData.LabelCommandOtherEvents = otherEvents } else if (hasReaction || hasStopAfter || hasStatusComment) && len(otherEvents) > 0 { // Only re-marshal the "on" if we have to onEventsYAML, err := yaml.Marshal(map[string]any{"on": otherEvents}) diff --git a/pkg/workflow/compiler_types.go b/pkg/workflow/compiler_types.go index e249912592..32ed866b6c 100644 --- a/pkg/workflow/compiler_types.go +++ b/pkg/workflow/compiler_types.go @@ -385,6 +385,9 @@ type WorkflowData struct { Command []string // for /command trigger support - multiple command names CommandEvents []string // events where command should be active (nil = all events) CommandOtherEvents map[string]any // for merging command with other events + LabelCommand []string // for label-command trigger support - label names that act as commands + LabelCommandEvents []string // events where label-command should be active (nil = all: issues, pull_request, discussion) + LabelCommandOtherEvents map[string]any // for merging label-command with other events AIReaction string // AI reaction type like "eyes", "heart", etc. StatusComment *bool // whether to post status comments (default: true when ai-reaction is set, false otherwise) ActivationGitHubToken string // custom github token from on.github-token for reactions/comments diff --git a/pkg/workflow/frontmatter_extraction_yaml.go b/pkg/workflow/frontmatter_extraction_yaml.go index f129087d0b..7b4c2fce28 100644 --- a/pkg/workflow/frontmatter_extraction_yaml.go +++ b/pkg/workflow/frontmatter_extraction_yaml.go @@ -690,6 +690,73 @@ func (c *Compiler) extractCommandConfig(frontmatter map[string]any) (commandName return nil, nil } +// extractLabelCommandConfig extracts the label-command configuration from frontmatter +// including label name(s) and the events field. +// It reads on.label_command which can be: +// - a string: label name directly (e.g. label_command: "deploy") +// - a map with "name" or "names" and optional "events" fields +// +// Returns (labelNames, labelEvents) where labelEvents is nil for default (all events). +func (c *Compiler) extractLabelCommandConfig(frontmatter map[string]any) (labelNames []string, labelEvents []string) { + frontmatterLog.Print("Extracting label-command configuration from frontmatter") + onValue, exists := frontmatter["on"] + if !exists { + return nil, nil + } + onMap, ok := onValue.(map[string]any) + if !ok { + return nil, nil + } + labelCommandValue, hasLabelCommand := onMap["label_command"] + if !hasLabelCommand { + return nil, nil + } + + // Simple string form: label_command: "my-label" + if nameStr, ok := labelCommandValue.(string); ok { + frontmatterLog.Printf("Extracted label-command name (shorthand): %s", nameStr) + return []string{nameStr}, nil + } + + // Map form: label_command: {name: "...", names: [...], events: [...]} + if lcMap, ok := labelCommandValue.(map[string]any); ok { + var names []string + var events []string + + if nameVal, hasName := lcMap["name"]; hasName { + if nameStr, ok := nameVal.(string); ok { + names = []string{nameStr} + } else if nameArray, ok := nameVal.([]any); ok { + for _, item := range nameArray { + if s, ok := item.(string); ok { + names = append(names, s) + } + } + } + } + if namesVal, hasNames := lcMap["names"]; hasNames { + if namesArray, ok := namesVal.([]any); ok { + for _, item := range namesArray { + if s, ok := item.(string); ok { + names = append(names, s) + } + } + } else if namesStr, ok := namesVal.(string); ok { + names = append(names, namesStr) + } + } + + if eventsVal, hasEvents := lcMap["events"]; hasEvents { + events = ParseCommandEvents(eventsVal) + } + + frontmatterLog.Printf("Extracted label-command config: names=%v, events=%v", names, events) + return names, events + } + + return nil, nil +} + // isGitHubAppNestedField returns true if the trimmed YAML line represents a known // nested field or array item inside an on.github-app object. func isGitHubAppNestedField(trimmedLine string) bool { diff --git a/pkg/workflow/label_command.go b/pkg/workflow/label_command.go new file mode 100644 index 0000000000..f46c3393b2 --- /dev/null +++ b/pkg/workflow/label_command.go @@ -0,0 +1,95 @@ +package workflow + +import ( + "errors" + "slices" + + "github.com/github/gh-aw/pkg/logger" +) + +var labelCommandLog = logger.New("workflow:label_command") + +// labelCommandSupportedEvents defines the GitHub Actions events that support label-command triggers +var labelCommandSupportedEvents = []string{"issues", "pull_request", "discussion"} + +// FilterLabelCommandEvents returns the label-command events to use based on the specified identifiers. +// If identifiers is nil or empty, returns all supported events. +func FilterLabelCommandEvents(identifiers []string) []string { + if len(identifiers) == 0 { + labelCommandLog.Print("No label-command event identifiers specified, returning all events") + return labelCommandSupportedEvents + } + + var result []string + for _, id := range identifiers { + if slices.Contains(labelCommandSupportedEvents, id) { + result = append(result, id) + } + } + + labelCommandLog.Printf("Filtered label-command events: %v -> %v", identifiers, result) + return result +} + +// buildLabelCommandCondition creates a condition that checks whether the triggering label +// matches one of the configured label-command names. For non-label events (e.g. +// workflow_dispatch and any other events in LabelCommandOtherEvents), the condition +// passes unconditionally so that manual runs and other triggers still work. +func buildLabelCommandCondition(labelNames []string, labelCommandEvents []string, hasOtherEvents bool) (ConditionNode, error) { + labelCommandLog.Printf("Building label-command condition: labels=%v, events=%v, has_other_events=%t", + labelNames, labelCommandEvents, hasOtherEvents) + + if len(labelNames) == 0 { + return nil, errors.New("no label names provided for label-command trigger") + } + + filteredEvents := FilterLabelCommandEvents(labelCommandEvents) + if len(filteredEvents) == 0 { + return nil, errors.New("no valid events specified for label-command trigger") + } + + // Build the label-name match condition: label1 == name OR label2 == name ... + var labelNameChecks []ConditionNode + for _, labelName := range labelNames { + labelNameChecks = append(labelNameChecks, BuildEquals( + BuildPropertyAccess("github.event.label.name"), + BuildStringLiteral(labelName), + )) + } + var labelNameMatch ConditionNode + if len(labelNameChecks) == 1 { + labelNameMatch = labelNameChecks[0] + } else { + labelNameMatch = BuildDisjunction(false, labelNameChecks...) + } + + // Build per-event checks: (event_name == 'issues' AND label matches) OR ... + var eventChecks []ConditionNode + for _, event := range filteredEvents { + eventChecks = append(eventChecks, &AndNode{ + Left: BuildEventTypeEquals(event), + Right: labelNameMatch, + }) + } + labelCondition := BuildDisjunction(false, eventChecks...) + + if !hasOtherEvents { + // No other events — the label condition is the entire condition. + return labelCondition, nil + } + + // When there are other events (e.g. workflow_dispatch from the expanded shorthand, or + // user-supplied events), we allow non-label events through unconditionally and only + // require the label-name check for label events. + var labelEventChecks []ConditionNode + for _, event := range filteredEvents { + labelEventChecks = append(labelEventChecks, BuildEventTypeEquals(event)) + } + isLabelEvent := BuildDisjunction(false, labelEventChecks...) + isNotLabelEvent := &NotNode{Child: isLabelEvent} + + return &OrNode{ + Left: &AndNode{Left: isLabelEvent, Right: labelNameMatch}, + Right: isNotLabelEvent, + }, nil +} diff --git a/pkg/workflow/label_command_parser.go b/pkg/workflow/label_command_parser.go new file mode 100644 index 0000000000..0115a0b89b --- /dev/null +++ b/pkg/workflow/label_command_parser.go @@ -0,0 +1,19 @@ +package workflow + +import ( + "github.com/github/gh-aw/pkg/logger" +) + +var labelCommandParserLog = logger.New("workflow:label_command_parser") + +// expandLabelCommandShorthand takes a label name and returns a map that represents +// the expanded label_command + workflow_dispatch configuration. +// This is the intermediate form stored in the frontmatter "on" map before +// parseOnSection processes it into WorkflowData.LabelCommand. +func expandLabelCommandShorthand(labelName string) map[string]any { + labelCommandParserLog.Printf("Expanding label-command shorthand for label: %s", labelName) + return map[string]any{ + "label_command": labelName, + "workflow_dispatch": nil, + } +} diff --git a/pkg/workflow/label_command_test.go b/pkg/workflow/label_command_test.go new file mode 100644 index 0000000000..ac23adef95 --- /dev/null +++ b/pkg/workflow/label_command_test.go @@ -0,0 +1,408 @@ +//go:build !integration + +package workflow + +import ( + "os" + "path/filepath" + "strings" + "testing" + + "github.com/github/gh-aw/pkg/stringutil" + + "github.com/goccy/go-yaml" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// TestLabelCommandShorthandPreprocessing verifies that "label-command " shorthand +// is expanded into the label_command map form by the schedule preprocessor. +func TestLabelCommandShorthandPreprocessing(t *testing.T) { + tests := []struct { + name string + onValue string + wantLabelName string + wantErr bool + }{ + { + name: "simple label-command shorthand", + onValue: "label-command deploy", + wantLabelName: "deploy", + }, + { + name: "label-command with hyphenated label", + onValue: "label-command needs-review", + wantLabelName: "needs-review", + }, + { + name: "label-command without label name", + onValue: "label-command ", + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + frontmatter := map[string]any{ + "on": tt.onValue, + } + + compiler := NewCompiler() + err := compiler.preprocessScheduleFields(frontmatter, "", "") + if tt.wantErr { + assert.Error(t, err, "expected error for input %q", tt.onValue) + return + } + + require.NoError(t, err, "preprocessScheduleFields() should not error") + + onVal := frontmatter["on"] + onMap, ok := onVal.(map[string]any) + require.True(t, ok, "on field should be a map after expansion, got %T", onVal) + + labelCmd, hasLabel := onMap["label_command"] + require.True(t, hasLabel, "on map should have label_command key") + assert.Equal(t, tt.wantLabelName, labelCmd, + "label_command value should be %q", tt.wantLabelName) + + _, hasDispatch := onMap["workflow_dispatch"] + assert.True(t, hasDispatch, "on map should have workflow_dispatch key") + }) + } +} + +// TestExpandLabelCommandShorthand verifies the expand helper function. +func TestExpandLabelCommandShorthand(t *testing.T) { + result := expandLabelCommandShorthand("deploy") + + labelCmd, ok := result["label_command"] + require.True(t, ok, "expanded map should have label_command key") + assert.Equal(t, "deploy", labelCmd, "label_command should equal the label name") + + _, hasDispatch := result["workflow_dispatch"] + assert.True(t, hasDispatch, "expanded map should have workflow_dispatch key") +} + +// TestFilterLabelCommandEvents verifies that FilterLabelCommandEvents returns correct subsets. +func TestFilterLabelCommandEvents(t *testing.T) { + tests := []struct { + name string + identifiers []string + want []string + }{ + { + name: "nil identifiers returns all events", + identifiers: nil, + want: []string{"issues", "pull_request", "discussion"}, + }, + { + name: "empty identifiers returns all events", + identifiers: []string{}, + want: []string{"issues", "pull_request", "discussion"}, + }, + { + name: "single issues event", + identifiers: []string{"issues"}, + want: []string{"issues"}, + }, + { + name: "issues and pull_request only", + identifiers: []string{"issues", "pull_request"}, + want: []string{"issues", "pull_request"}, + }, + { + name: "unsupported event is filtered out", + identifiers: []string{"issues", "unknown_event"}, + want: []string{"issues"}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := FilterLabelCommandEvents(tt.identifiers) + assert.Equal(t, tt.want, got, "FilterLabelCommandEvents(%v)", tt.identifiers) + }) + } +} + +// TestBuildLabelCommandCondition verifies the condition builder for label-command triggers. +func TestBuildLabelCommandCondition(t *testing.T) { + tests := []struct { + name string + labelNames []string + events []string + hasOtherEvents bool + wantErr bool + wantContains []string + wantNotContains []string + }{ + { + name: "single label all events no other events", + labelNames: []string{"deploy"}, + events: nil, + wantContains: []string{ + "github.event.label.name == 'deploy'", + "github.event_name == 'issues'", + "github.event_name == 'pull_request'", + "github.event_name == 'discussion'", + }, + }, + { + name: "multiple labels all events", + labelNames: []string{"deploy", "release"}, + events: nil, + wantContains: []string{ + "github.event.label.name == 'deploy'", + "github.event.label.name == 'release'", + }, + }, + { + name: "single label issues only", + labelNames: []string{"triage"}, + events: []string{"issues"}, + wantContains: []string{ + "github.event_name == 'issues'", + "github.event.label.name == 'triage'", + }, + wantNotContains: []string{ + "github.event_name == 'pull_request'", + "github.event_name == 'discussion'", + }, + }, + { + name: "no label names returns error", + labelNames: []string{}, + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + condition, err := buildLabelCommandCondition(tt.labelNames, tt.events, tt.hasOtherEvents) + if tt.wantErr { + assert.Error(t, err, "expected an error") + return + } + + require.NoError(t, err, "buildLabelCommandCondition() should not error") + rendered := condition.Render() + + for _, want := range tt.wantContains { + assert.Contains(t, rendered, want, + "condition should contain %q, got: %s", want, rendered) + } + for _, notWant := range tt.wantNotContains { + assert.NotContains(t, rendered, notWant, + "condition should NOT contain %q, got: %s", notWant, rendered) + } + }) + } +} + +// TestLabelCommandWorkflowCompile verifies that a workflow with label_command trigger +// compiles to a valid GitHub Actions workflow with: +// - label-based events (issues, pull_request, discussion) in the on: section +// - workflow_dispatch with item_number input +// - a label-name condition in the activation job's if: +// - a remove_trigger_label step in the activation job +// - a label_command output on the activation job +func TestLabelCommandWorkflowCompile(t *testing.T) { + tempDir := t.TempDir() + + workflowContent := `--- +name: Label Command Test +on: + label_command: deploy +engine: copilot +--- + +Deploy the application because label "deploy" was added. +` + + workflowPath := filepath.Join(tempDir, "label-command-test.md") + err := os.WriteFile(workflowPath, []byte(workflowContent), 0644) + require.NoError(t, err, "failed to write test workflow") + + compiler := NewCompiler() + err = compiler.CompileWorkflow(workflowPath) + require.NoError(t, err, "CompileWorkflow() should not error") + + lockFilePath := stringutil.MarkdownToLockFile(workflowPath) + lockContent, err := os.ReadFile(lockFilePath) + require.NoError(t, err, "failed to read lock file") + + lockStr := string(lockContent) + + // Verify the on: section includes label-based events + assert.Contains(t, lockStr, "issues:", "on section should contain issues event") + assert.Contains(t, lockStr, "pull_request:", "on section should contain pull_request event") + assert.Contains(t, lockStr, "discussion:", "on section should contain discussion event") + assert.Contains(t, lockStr, "labeled", "on section should contain labeled type") + assert.Contains(t, lockStr, "workflow_dispatch:", "on section should contain workflow_dispatch") + assert.Contains(t, lockStr, "item_number:", "workflow_dispatch should include item_number input") + + // Parse the YAML to check the activation job + var workflow map[string]any + err = yaml.Unmarshal(lockContent, &workflow) + require.NoError(t, err, "failed to parse lock file as YAML") + + jobs, ok := workflow["jobs"].(map[string]any) + require.True(t, ok, "workflow should have jobs") + + activation, ok := jobs["activation"].(map[string]any) + require.True(t, ok, "workflow should have an activation job") + + // Verify the activation job has a label_command output + activationOutputs, ok := activation["outputs"].(map[string]any) + require.True(t, ok, "activation job should have outputs") + + labelCmdOutput, hasOutput := activationOutputs["label_command"] + assert.True(t, hasOutput, "activation job should have label_command output") + assert.Contains(t, labelCmdOutput, "remove_trigger_label", + "label_command output should reference the remove_trigger_label step") + + // Verify the remove_trigger_label step exists in the activation job + activationSteps, ok := activation["steps"].([]any) + require.True(t, ok, "activation job should have steps") + + foundRemoveStep := false + for _, step := range activationSteps { + stepMap, ok := step.(map[string]any) + if !ok { + continue + } + if id, ok := stepMap["id"].(string); ok && id == "remove_trigger_label" { + foundRemoveStep = true + break + } + } + assert.True(t, foundRemoveStep, "activation job should contain a remove_trigger_label step") + + // Verify the workflow condition includes the label name check + agentJob, hasAgent := jobs["agent"].(map[string]any) + require.True(t, hasAgent, "workflow should have an agent job") + _ = agentJob // presence check is sufficient +} + +// TestLabelCommandWorkflowCompileShorthand verifies the "label-command " string shorthand. +func TestLabelCommandWorkflowCompileShorthand(t *testing.T) { + tempDir := t.TempDir() + + workflowContent := `--- +name: Label Command Shorthand Test +on: "label-command needs-review" +engine: copilot +--- + +Triggered by the needs-review label. +` + + workflowPath := filepath.Join(tempDir, "label-command-shorthand.md") + err := os.WriteFile(workflowPath, []byte(workflowContent), 0644) + require.NoError(t, err, "failed to write test workflow") + + compiler := NewCompiler() + err = compiler.CompileWorkflow(workflowPath) + require.NoError(t, err, "CompileWorkflow() should not error for shorthand form") + + lockFilePath := stringutil.MarkdownToLockFile(workflowPath) + lockContent, err := os.ReadFile(lockFilePath) + require.NoError(t, err, "failed to read lock file") + + lockStr := string(lockContent) + assert.Contains(t, lockStr, "labeled", "compiled workflow should contain labeled type") + assert.Contains(t, lockStr, "remove_trigger_label", "compiled workflow should contain remove_trigger_label step") +} + +// TestLabelCommandWorkflowWithEvents verifies that specifying events: restricts +// which GitHub Actions events are generated. +func TestLabelCommandWorkflowWithEvents(t *testing.T) { + tempDir := t.TempDir() + + workflowContent := `--- +name: Label Command Issues Only +on: + label_command: + name: deploy + events: [issues] +engine: copilot +--- + +Triggered by the deploy label on issues only. +` + + workflowPath := filepath.Join(tempDir, "label-command-issues-only.md") + err := os.WriteFile(workflowPath, []byte(workflowContent), 0644) + require.NoError(t, err, "failed to write test workflow") + + compiler := NewCompiler() + err = compiler.CompileWorkflow(workflowPath) + require.NoError(t, err, "CompileWorkflow() should not error") + + lockFilePath := stringutil.MarkdownToLockFile(workflowPath) + lockContent, err := os.ReadFile(lockFilePath) + require.NoError(t, err, "failed to read lock file") + + lockStr := string(lockContent) + + // Should have issues event + assert.Contains(t, lockStr, "issues:", "on section should contain issues event") + + // workflow_dispatch is always added + assert.Contains(t, lockStr, "workflow_dispatch:", "on section should contain workflow_dispatch") + + // pull_request and discussion should NOT be present since events: [issues] was specified + // (However, they may be commented or absent — check the YAML structure) + var workflow map[string]any + err = yaml.Unmarshal(lockContent, &workflow) + require.NoError(t, err, "failed to parse lock file as YAML") + + onSection, ok := workflow["on"].(map[string]any) + require.True(t, ok, "workflow on: section should be a map") + + _, hasPR := onSection["pull_request"] + assert.False(t, hasPR, "pull_request event should not be present when events=[issues]") + + _, hasDiscussion := onSection["discussion"] + assert.False(t, hasDiscussion, "discussion event should not be present when events=[issues]") +} + +// TestLabelCommandNoClashWithExistingLabelTrigger verifies that label_command can coexist +// with a regular label trigger without creating a duplicate issues: YAML block. +func TestLabelCommandNoClashWithExistingLabelTrigger(t *testing.T) { + tempDir := t.TempDir() + + // Workflow that has both a regular label trigger (schedule via default) and label_command + workflowContent := `--- +name: No Clash Test +on: + label_command: deploy + schedule: + - cron: "0 * * * *" +engine: copilot +--- + +Both label-command and scheduled trigger. +` + + workflowPath := filepath.Join(tempDir, "no-clash-test.md") + err := os.WriteFile(workflowPath, []byte(workflowContent), 0644) + require.NoError(t, err, "failed to write test workflow") + + compiler := NewCompiler() + err = compiler.CompileWorkflow(workflowPath) + require.NoError(t, err, "CompileWorkflow() should not error when mixing label_command and other triggers") + + lockFilePath := stringutil.MarkdownToLockFile(workflowPath) + lockContent, err := os.ReadFile(lockFilePath) + require.NoError(t, err, "failed to read lock file") + + lockStr := string(lockContent) + + // Verify there is exactly ONE "issues:" block at the YAML top level + // (count occurrences that are a key, not embedded in other values) + issuesCount := strings.Count(lockStr, "\n issues:\n") + strings.Count(lockStr, "\nissues:\n") + assert.Equal(t, 1, issuesCount, + "there should be exactly one 'issues:' trigger block in the compiled YAML, got %d. Compiled:\n%s", + issuesCount, lockStr) +} diff --git a/pkg/workflow/schedule_preprocessing.go b/pkg/workflow/schedule_preprocessing.go index db346221cd..09aff29557 100644 --- a/pkg/workflow/schedule_preprocessing.go +++ b/pkg/workflow/schedule_preprocessing.go @@ -128,6 +128,18 @@ func (c *Compiler) preprocessScheduleFields(frontmatter map[string]any, markdown return nil } + // Check if it's a label-command shorthand (starts with "label-command ") + if labelName, ok := strings.CutPrefix(onStr, "label-command "); ok { + labelName = strings.TrimSpace(labelName) + if labelName == "" { + return errors.New("label-command shorthand requires a label name after 'label-command'") + } + schedulePreprocessingLog.Printf("Converting shorthand 'on: %s' to label_command + workflow_dispatch", onStr) + onMap := expandLabelCommandShorthand(labelName) + frontmatter["on"] = onMap + return nil + } + // Check if it's a label trigger shorthand (labeled label1 label2...) entityType, labelNames, isLabelTrigger, err := parseLabelTriggerShorthand(onStr) if err != nil { diff --git a/pkg/workflow/tools.go b/pkg/workflow/tools.go index 0954a0cf01..f58ff2043c 100644 --- a/pkg/workflow/tools.go +++ b/pkg/workflow/tools.go @@ -22,11 +22,14 @@ func (c *Compiler) applyDefaults(data *WorkflowData, markdownPath string) error // Check if this is a command trigger workflow (by checking if user specified "on.command") isCommandTrigger := false + isLabelCommandTrigger := false if data.On == "" { // parseOnSection may have already detected the command trigger and populated data.Command // (this covers slash_command map format, slash_command shorthand "on: /name", and deprecated "command:") if len(data.Command) > 0 { isCommandTrigger = true + } else if len(data.LabelCommand) > 0 { + isLabelCommandTrigger = true } else { // Check the original frontmatter for command trigger content, err := os.ReadFile(markdownPath) @@ -40,6 +43,8 @@ func (c *Compiler) applyDefaults(data *WorkflowData, markdownPath string) error isCommandTrigger = true } else if _, hasCommand := onMap["command"]; hasCommand { isCommandTrigger = true + } else if _, hasLabelCommand := onMap["label_command"]; hasLabelCommand { + isLabelCommandTrigger = true } } } @@ -114,6 +119,65 @@ func (c *Compiler) applyDefaults(data *WorkflowData, markdownPath string) error if data.If == "" { data.If = commandConditionTree.Render() } + } else if isLabelCommandTrigger { + toolsLog.Print("Workflow is label-command trigger, configuring label events") + + // Build the label-command events map + // Generate events: issues, pull_request, discussion with types: [labeled] + filteredEvents := FilterLabelCommandEvents(data.LabelCommandEvents) + labelEventsMap := make(map[string]any) + for _, eventName := range filteredEvents { + labelEventsMap[eventName] = map[string]any{ + "types": []any{"labeled"}, + } + } + + // Add workflow_dispatch with item_number input for manual testing + labelEventsMap["workflow_dispatch"] = map[string]any{ + "inputs": map[string]any{ + "item_number": map[string]any{ + "description": "The number of the issue, pull request, or discussion", + "required": true, + "type": "string", + }, + }, + } + + // Merge other events (if any) — this handles the no-clash requirement: + // if the user also has e.g. "issues: {types: [labeled], names: [bug]}" as a + // regular label trigger alongside label_command, merge them rather than + // generating a duplicate "issues:" block. + if len(data.LabelCommandOtherEvents) > 0 { + for eventKey, eventVal := range data.LabelCommandOtherEvents { + if _, exists := labelEventsMap[eventKey]; exists { + // Event already present from label_command generation — keep ours + // (the condition handles filtering by label name at job level) + continue + } + labelEventsMap[eventKey] = eventVal + } + } + + // Convert merged events to YAML + mergedEventsYAML, err := yaml.Marshal(map[string]any{"on": labelEventsMap}) + if err == nil { + yamlStr := strings.TrimSuffix(string(mergedEventsYAML), "\n") + yamlStr = parser.QuoteCronExpressions(yamlStr) + // Pass frontmatter so label names in "names:" fields get commented out + yamlStr = c.commentOutProcessedFieldsInOnSection(yamlStr, map[string]any{}) + data.On = yamlStr + } + + // Build the label-command condition + hasOtherEvents := len(data.LabelCommandOtherEvents) > 0 + labelConditionTree, err := buildLabelCommandCondition(data.LabelCommand, data.LabelCommandEvents, hasOtherEvents) + if err != nil { + return fmt.Errorf("failed to build label-command condition: %w", err) + } + + if data.If == "" { + data.If = labelConditionTree.Render() + } } else { data.On = `on: # Start either every 10 minutes, or when some kind of human event occurs. @@ -141,7 +205,7 @@ func (c *Compiler) applyDefaults(data *WorkflowData, markdownPath string) error } // Generate concurrency configuration using the dedicated concurrency module - data.Concurrency = GenerateConcurrencyConfig(data, isCommandTrigger) + data.Concurrency = GenerateConcurrencyConfig(data, isCommandTrigger || isLabelCommandTrigger) if data.RunName == "" { data.RunName = fmt.Sprintf(`run-name: "%s"`, data.Name) From d75f850a0f325314457a90f872f35313736b0558 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 15 Mar 2026 20:52:26 +0000 Subject: [PATCH 03/11] fix: address code review comments - handle json.Marshal error, simplify owner/repo extraction Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> --- actions/setup/js/remove_trigger_label.cjs | 7 ++++++- pkg/workflow/compiler_activation_job.go | 5 ++++- 2 files changed, 10 insertions(+), 2 deletions(-) diff --git a/actions/setup/js/remove_trigger_label.cjs b/actions/setup/js/remove_trigger_label.cjs index 047ade88e4..876c3e43b7 100644 --- a/actions/setup/js/remove_trigger_label.cjs +++ b/actions/setup/js/remove_trigger_label.cjs @@ -58,7 +58,12 @@ async function main() { core.info(`Removing trigger label '${triggerLabel}' (event: ${eventName})`); - const [owner, repo] = context.repo ? [context.repo.owner, context.repo.repo] : (context.payload?.repository?.full_name ?? "/").split("/"); + const owner = context.repo?.owner; + const repo = context.repo?.repo; + if (!owner || !repo) { + core.setFailed(`${ERR_CONFIG}: Configuration error: Unable to determine repository owner/name from context.`); + return; + } try { if (eventName === "issues") { diff --git a/pkg/workflow/compiler_activation_job.go b/pkg/workflow/compiler_activation_job.go index 004c8c4d0f..046bb89e60 100644 --- a/pkg/workflow/compiler_activation_job.go +++ b/pkg/workflow/compiler_activation_job.go @@ -296,7 +296,10 @@ func (c *Compiler) buildActivationJob(data *WorkflowData, preActivationJobCreate steps = append(steps, fmt.Sprintf(" uses: %s\n", GetActionPin("actions/github-script"))) steps = append(steps, " env:\n") // Pass label names as a JSON array so the script can validate the label - labelNamesJSON, _ := json.Marshal(data.LabelCommand) + labelNamesJSON, err := json.Marshal(data.LabelCommand) + if err != nil { + return nil, fmt.Errorf("failed to marshal label-command names: %w", err) + } steps = append(steps, fmt.Sprintf(" GH_AW_LABEL_NAMES: %q\n", string(labelNamesJSON))) steps = append(steps, " with:\n") steps = append(steps, " script: |\n") From d8365f0ef4512c0629fa1cc96cba6f59f9e3768a Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 15 Mar 2026 21:12:53 +0000 Subject: [PATCH 04/11] feat: enable label-command on cloclo (cloclo label) and smoke-copilot (smoke label) Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> --- .github/workflows/cloclo.lock.yml | 41 ++++++++++++--------- .github/workflows/cloclo.md | 4 +-- .github/workflows/smoke-copilot.lock.yml | 45 ++++++++++++------------ .github/workflows/smoke-copilot.md | 6 ++-- pkg/workflow/tools.go | 40 ++++++++++++++++++++- 5 files changed, 91 insertions(+), 45 deletions(-) diff --git a/.github/workflows/cloclo.lock.yml b/.github/workflows/cloclo.lock.yml index 05ad6bbc54..131ed4440e 100644 --- a/.github/workflows/cloclo.lock.yml +++ b/.github/workflows/cloclo.lock.yml @@ -27,7 +27,7 @@ # - shared/jqschema.md # - shared/mcp/serena-go.md # -# gh-aw-metadata: {"schema_version":"v2","frontmatter_hash":"183df887ed669775015161c11ab4c4b3f52f06a00a94ec9d64785a0eda3be9c2","strict":true} +# gh-aw-metadata: {"schema_version":"v2","frontmatter_hash":"ad111cf099d958c340ed839a8e88f5053a501ce37fb239bdc2645ee1a872d14a","strict":true} name: "/cloclo" "on": @@ -35,6 +35,7 @@ name: "/cloclo" types: - created - edited + - labeled discussion_comment: types: - created @@ -44,15 +45,17 @@ name: "/cloclo" - created - edited issues: - # names: # Label filtering applied via job conditions - # - cloclo # Label filtering applied via job conditions types: + - opened + - edited + - reopened - labeled pull_request: types: - opened - edited - reopened + - labeled pull_request_review_comment: types: - created @@ -70,9 +73,7 @@ jobs: activation: needs: pre_activation if: > - (needs.pre_activation.outputs.activated == 'true') && ((((github.event_name == 'issues' || github.event_name == 'issue_comment' || - github.event_name == 'pull_request' || github.event_name == 'pull_request_review_comment' || github.event_name == 'discussion' || - github.event_name == 'discussion_comment') && ((github.event_name == 'issues') && ((startsWith(github.event.issue.body, '/cloclo ')) || + (needs.pre_activation.outputs.activated == 'true') && (((github.event_name == 'issues') && ((startsWith(github.event.issue.body, '/cloclo ')) || (github.event.issue.body == '/cloclo')) || (github.event_name == 'issue_comment') && (((startsWith(github.event.comment.body, '/cloclo ')) || (github.event.comment.body == '/cloclo')) && (github.event.issue.pull_request == null)) || (github.event_name == 'issue_comment') && (((startsWith(github.event.comment.body, '/cloclo ')) || (github.event.comment.body == '/cloclo')) && @@ -82,10 +83,9 @@ jobs: ((startsWith(github.event.pull_request.body, '/cloclo ')) || (github.event.pull_request.body == '/cloclo')) || (github.event_name == 'discussion') && ((startsWith(github.event.discussion.body, '/cloclo ')) || (github.event.discussion.body == '/cloclo')) || (github.event_name == 'discussion_comment') && ((startsWith(github.event.comment.body, '/cloclo ')) || - (github.event.comment.body == '/cloclo')))) || (!(github.event_name == 'issues' || github.event_name == 'issue_comment' || - github.event_name == 'pull_request' || github.event_name == 'pull_request_review_comment' || github.event_name == 'discussion' || - github.event_name == 'discussion_comment'))) && ((github.event_name != 'issues') || ((github.event.action != 'labeled') || - (github.event.label.name == 'cloclo')))) + (github.event.comment.body == '/cloclo'))) || ((github.event_name == 'issues') && (github.event.label.name == 'cloclo') || + (github.event_name == 'pull_request') && (github.event.label.name == 'cloclo') || (github.event_name == 'discussion') && + (github.event.label.name == 'cloclo'))) runs-on: ubuntu-slim permissions: contents: read @@ -97,6 +97,7 @@ jobs: comment_id: ${{ steps.add-comment.outputs.comment-id }} comment_repo: ${{ steps.add-comment.outputs.comment-repo }} comment_url: ${{ steps.add-comment.outputs.comment-url }} + label_command: ${{ steps.remove_trigger_label.outputs.label_name }} model: ${{ steps.generate_aw_info.outputs.model }} secret_verification_result: ${{ steps.validate-secret.outputs.verification_result }} slash_command: ${{ needs.pre_activation.outputs.matched_command }} @@ -196,6 +197,17 @@ jobs: setupGlobals(core, github, context, exec, io); const { main } = require('/opt/gh-aw/actions/add_workflow_run_comment.cjs'); await main(); + - name: Remove trigger label + id: remove_trigger_label + uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 + env: + GH_AW_LABEL_NAMES: "[\"cloclo\"]" + with: + script: | + const { setupGlobals } = require('/opt/gh-aw/actions/setup_globals.cjs'); + setupGlobals(core, github, context, exec, io); + const { main } = require('/opt/gh-aw/actions/remove_trigger_label.cjs'); + await main(); - name: Create prompt with built-in context env: GH_AW_PROMPT: /tmp/gh-aw/aw-prompts/prompt.txt @@ -1533,8 +1545,6 @@ jobs: pre_activation: if: > - (((github.event_name == 'issues' || github.event_name == 'issue_comment' || github.event_name == 'pull_request' || - github.event_name == 'pull_request_review_comment' || github.event_name == 'discussion' || github.event_name == 'discussion_comment') && ((github.event_name == 'issues') && ((startsWith(github.event.issue.body, '/cloclo ')) || (github.event.issue.body == '/cloclo')) || (github.event_name == 'issue_comment') && (((startsWith(github.event.comment.body, '/cloclo ')) || (github.event.comment.body == '/cloclo')) && (github.event.issue.pull_request == null)) || (github.event_name == 'issue_comment') && (((startsWith(github.event.comment.body, '/cloclo ')) || @@ -1544,10 +1554,9 @@ jobs: ((startsWith(github.event.pull_request.body, '/cloclo ')) || (github.event.pull_request.body == '/cloclo')) || (github.event_name == 'discussion') && ((startsWith(github.event.discussion.body, '/cloclo ')) || (github.event.discussion.body == '/cloclo')) || (github.event_name == 'discussion_comment') && ((startsWith(github.event.comment.body, '/cloclo ')) || - (github.event.comment.body == '/cloclo')))) || (!(github.event_name == 'issues' || github.event_name == 'issue_comment' || - github.event_name == 'pull_request' || github.event_name == 'pull_request_review_comment' || github.event_name == 'discussion' || - github.event_name == 'discussion_comment'))) && ((github.event_name != 'issues') || ((github.event.action != 'labeled') || - (github.event.label.name == 'cloclo'))) + (github.event.comment.body == '/cloclo'))) || ((github.event_name == 'issues') && (github.event.label.name == 'cloclo') || + (github.event_name == 'pull_request') && (github.event.label.name == 'cloclo') || (github.event_name == 'discussion') && + (github.event.label.name == 'cloclo')) runs-on: ubuntu-slim permissions: contents: read diff --git a/.github/workflows/cloclo.md b/.github/workflows/cloclo.md index 6cba6c17f2..77103bd6ab 100644 --- a/.github/workflows/cloclo.md +++ b/.github/workflows/cloclo.md @@ -2,9 +2,7 @@ on: slash_command: name: cloclo - issues: - types: [labeled] - names: [cloclo] + label_command: cloclo status-comment: true permissions: contents: read diff --git a/.github/workflows/smoke-copilot.lock.yml b/.github/workflows/smoke-copilot.lock.yml index 2fd336ceb0..76ff7353b0 100644 --- a/.github/workflows/smoke-copilot.lock.yml +++ b/.github/workflows/smoke-copilot.lock.yml @@ -29,24 +29,26 @@ # - shared/github-queries-mcp-script.md # - shared/reporting.md # -# gh-aw-metadata: {"schema_version":"v2","frontmatter_hash":"00655b0e7e36f5b9cd4f2463f6d1fa4626f10db1abd0ed26dae87c1fa244d4dd","strict":true} +# gh-aw-metadata: {"schema_version":"v2","frontmatter_hash":"b8385d3a5fc381c62fd4658d02d54168dea724b8f222a4e71df570339fa0d084","strict":true} name: "Smoke Copilot" "on": pull_request: - # names: # Label filtering applied via job conditions - # - smoke # Label filtering applied via job conditions types: - labeled schedule: - cron: "47 */12 * * *" - workflow_dispatch: null + workflow_dispatch: + inputs: + item_number: + description: The number of the issue, pull request, or discussion + required: true + type: string permissions: {} concurrency: - group: "gh-aw-${{ github.workflow }}-${{ github.event.pull_request.number || github.ref || github.run_id }}" - cancel-in-progress: true + group: "gh-aw-${{ github.workflow }}-${{ github.event.issue.number || github.event.pull_request.number || github.run_id }}" run-name: "Smoke Copilot" @@ -54,8 +56,8 @@ jobs: activation: needs: pre_activation if: > - (needs.pre_activation.outputs.activated == 'true') && (((github.event_name != 'pull_request') || (github.event.pull_request.head.repo.id == github.repository_id)) && - ((github.event_name != 'pull_request') || ((github.event.action != 'labeled') || (github.event.label.name == 'smoke')))) + (needs.pre_activation.outputs.activated == 'true') && (((github.event_name == 'pull_request') && (github.event.label.name == 'smoke')) || + (!(github.event_name == 'pull_request'))) runs-on: ubuntu-slim permissions: contents: read @@ -63,14 +65,12 @@ jobs: issues: write pull-requests: write outputs: - body: ${{ steps.sanitized.outputs.body }} comment_id: ${{ steps.add-comment.outputs.comment-id }} comment_repo: ${{ steps.add-comment.outputs.comment-repo }} comment_url: ${{ steps.add-comment.outputs.comment-url }} + label_command: ${{ steps.remove_trigger_label.outputs.label_name }} model: ${{ steps.generate_aw_info.outputs.model }} secret_verification_result: ${{ steps.validate-secret.outputs.verification_result }} - text: ${{ steps.sanitized.outputs.text }} - title: ${{ steps.sanitized.outputs.title }} steps: - name: Checkout actions folder uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 @@ -143,15 +143,6 @@ jobs: setupGlobals(core, github, context, exec, io); const { main } = require('/opt/gh-aw/actions/check_workflow_timestamp_api.cjs'); await main(); - - name: Compute current body text - id: sanitized - uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 - with: - script: | - const { setupGlobals } = require('/opt/gh-aw/actions/setup_globals.cjs'); - setupGlobals(core, github, context, exec, io); - const { main } = require('/opt/gh-aw/actions/compute_text.cjs'); - await main(); - name: Add comment with workflow run link id: add-comment if: github.event_name == 'issues' || github.event_name == 'issue_comment' || github.event_name == 'pull_request_review_comment' || github.event_name == 'discussion' || github.event_name == 'discussion_comment' || (github.event_name == 'pull_request') && (github.event.pull_request.head.repo.id == github.repository_id) @@ -166,6 +157,17 @@ jobs: setupGlobals(core, github, context, exec, io); const { main } = require('/opt/gh-aw/actions/add_workflow_run_comment.cjs'); await main(); + - name: Remove trigger label + id: remove_trigger_label + uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 + env: + GH_AW_LABEL_NAMES: "[\"smoke\"]" + with: + script: | + const { setupGlobals } = require('/opt/gh-aw/actions/setup_globals.cjs'); + setupGlobals(core, github, context, exec, io); + const { main } = require('/opt/gh-aw/actions/remove_trigger_label.cjs'); + await main(); - name: Create prompt with built-in context env: GH_AW_PROMPT: /tmp/gh-aw/aw-prompts/prompt.txt @@ -2329,8 +2331,7 @@ jobs: pre_activation: if: > - ((github.event_name != 'pull_request') || (github.event.pull_request.head.repo.id == github.repository_id)) && - ((github.event_name != 'pull_request') || ((github.event.action != 'labeled') || (github.event.label.name == 'smoke'))) + ((github.event_name == 'pull_request') && (github.event.label.name == 'smoke')) || (!(github.event_name == 'pull_request')) runs-on: ubuntu-slim permissions: contents: read diff --git a/.github/workflows/smoke-copilot.md b/.github/workflows/smoke-copilot.md index 3c1753302d..70613d145a 100644 --- a/.github/workflows/smoke-copilot.md +++ b/.github/workflows/smoke-copilot.md @@ -3,9 +3,9 @@ description: Smoke Copilot on: schedule: every 12h workflow_dispatch: - pull_request: - types: [labeled] - names: ["smoke"] + label_command: + name: smoke + events: [pull_request] reaction: "eyes" status-comment: true github-token: ${{ secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} diff --git a/pkg/workflow/tools.go b/pkg/workflow/tools.go index f58ff2043c..e1a106870a 100644 --- a/pkg/workflow/tools.go +++ b/pkg/workflow/tools.go @@ -77,6 +77,33 @@ func (c *Compiler) applyDefaults(data *WorkflowData, markdownPath string) error maps.Copy(commandEventsMap, data.CommandOtherEvents) } + // If label_command is also configured alongside slash_command, merge label events + // into the existing command events map to avoid duplicate YAML keys. + if len(data.LabelCommand) > 0 { + labelEventNames := FilterLabelCommandEvents(data.LabelCommandEvents) + for _, eventName := range labelEventNames { + if existingAny, ok := commandEventsMap[eventName]; ok { + if existingMap, ok := existingAny.(map[string]any); ok { + switch t := existingMap["types"].(type) { + case []string: + newTypes := make([]any, len(t)+1) + for i, s := range t { + newTypes[i] = s + } + newTypes[len(t)] = "labeled" + existingMap["types"] = newTypes + case []any: + existingMap["types"] = append(t, "labeled") + } + } + } else { + commandEventsMap[eventName] = map[string]any{ + "types": []any{"labeled"}, + } + } + } + } + // Convert merged events to YAML mergedEventsYAML, err := yaml.Marshal(map[string]any{"on": commandEventsMap}) if err == nil { @@ -117,7 +144,18 @@ func (c *Compiler) applyDefaults(data *WorkflowData, markdownPath string) error } if data.If == "" { - data.If = commandConditionTree.Render() + if len(data.LabelCommand) > 0 { + // Combine: (slash_command condition) OR (label_command condition) + // This allows the workflow to activate via either mechanism. + labelConditionTree, err := buildLabelCommandCondition(data.LabelCommand, data.LabelCommandEvents, false) + if err != nil { + return fmt.Errorf("failed to build combined label-command condition: %w", err) + } + combined := &OrNode{Left: commandConditionTree, Right: labelConditionTree} + data.If = combined.Render() + } else { + data.If = commandConditionTree.Render() + } } } else if isLabelCommandTrigger { toolsLog.Print("Workflow is label-command trigger, configuring label events") From 36f9f3a340466be1e7bdb674408cfee5c258957e Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 15 Mar 2026 21:41:35 +0000 Subject: [PATCH 05/11] docs: document label_command trigger on LabelOps page Add a new 'Label Command Trigger' section explaining: - One-shot command semantics (label auto-removed after trigger) - All frontmatter syntax variants (shorthand, map, events restriction, multiple names) - workflow_dispatch added for manual testing - Accessing matched label via needs.activation.outputs.label_command - Combining label_command with slash_command in the same workflow - NOTE callout about required write permission Update the 'Label Filtering' section to clarify it is for state-based routing where the label stays on the item. Add a comparison table between label_command and names: filtering to help users choose. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- docs/src/content/docs/patterns/label-ops.md | 94 ++++++++++++++++++++- 1 file changed, 92 insertions(+), 2 deletions(-) diff --git a/docs/src/content/docs/patterns/label-ops.md b/docs/src/content/docs/patterns/label-ops.md index 4672f65a78..5f2e38e252 100644 --- a/docs/src/content/docs/patterns/label-ops.md +++ b/docs/src/content/docs/patterns/label-ops.md @@ -5,14 +5,95 @@ sidebar: badge: { text: 'Event-triggered', variant: 'success' } --- -LabelOps uses GitHub labels as workflow triggers, metadata, and state markers. GitHub Agentic Workflows supports label-based triggers with filtering to activate workflows only for specific label changes while maintaining secure, automated responses. +LabelOps uses GitHub labels as workflow triggers, metadata, and state markers. GitHub Agentic Workflows supports two distinct approaches to label-based triggers: `label_command` for command-style one-shot activation, and `names:` filtering for persistent label-state awareness. ## When to Use LabelOps Use LabelOps for priority-based workflows (run checks when `priority: high` is added), stage transitions (trigger actions when moving between workflow states), specialized processing (different workflows for different label categories), and team coordination (automate handoffs between teams using labels). +## Label Command Trigger + +The `label_command` trigger treats a label as a one-shot command: applying the label fires the workflow, and the label is **automatically removed** so it can be re-applied to re-trigger. This is the right choice when you want a label to mean "do this now" rather than "this item has this property." + +```aw wrap +--- +on: + label_command: deploy +permissions: + contents: read + actions: write +safe-outputs: + add-comment: + max: 1 +--- + +# Deploy Preview + +A `deploy` label was applied to this pull request. Build and deploy a preview environment and post the URL as a comment. + +The matched label name is available as `${{ needs.activation.outputs.label_command }}` if needed to distinguish between multiple label commands. +``` + +After activation the `deploy` label is removed from the pull request, so a reviewer can apply it again to trigger another deployment without any cleanup step. + +### Syntax + +`label_command` accepts a shorthand string, a map with a single name, or a map with multiple names and an optional `events` restriction: + +```yaml +# Shorthand — fires on issues, pull_request, and discussion +on: "label-command deploy" + +# Map with a single name +on: + label_command: deploy + +# Restrict to specific event types +on: + label_command: + name: deploy + events: [issues, pull_request] + +# Multiple label names +on: + label_command: + names: [deploy, redeploy] + events: [pull_request] +``` + +The compiler generates `issues`, `pull_request`, and/or `discussion` events with `types: [labeled]`, filtered to the named labels. It also adds a `workflow_dispatch` trigger with an `item_number` input so you can test the workflow manually without applying a real label. + +### Accessing the matched label + +The label that triggered the workflow is exposed as an output of the activation job: + +``` +${{ needs.activation.outputs.label_command }} +``` + +This is useful when a workflow handles multiple label commands and needs to branch on which one was applied. + +### Combining with slash commands + +`label_command` can be combined with `slash_command:` in the same workflow. The two triggers are OR'd — the workflow activates when either condition is met: + +```yaml +on: + slash_command: deploy + label_command: + name: deploy + events: [pull_request] +``` + +This lets a workflow be triggered both by a `/deploy` comment and by applying a `deploy` label, sharing the same agent logic. + +> [!NOTE] +> The automatic label removal requires `issues: write` or `pull-requests: write` permission (depending on item type). Add the relevant permission to your frontmatter when using `label_command`. + ## Label Filtering +Use `names:` filtering when you want the workflow to run whenever a label is present on an item and the label should remain attached. This is suitable for monitoring label state rather than reacting to a transient command. + GitHub Agentic Workflows allows you to filter `labeled` and `unlabeled` events to trigger only for specific label names using the `names` field: ```aw wrap @@ -42,7 +123,16 @@ Check the issue for: Respond with a comment outlining next steps and recommended actions. ``` -This workflow activates only when the `bug`, `critical`, or `security` labels are added to an issue, not for other label changes. +This workflow activates only when the `bug`, `critical`, or `security` labels are added to an issue, not for other label changes. The labels remain on the issue after the workflow runs. + +### Choosing between `label_command` and `names:` filtering + +| | `label_command` | `names:` filtering | +|---|---|---| +| Label lifecycle | Removed automatically after trigger | Stays on the item | +| Re-triggerable | Yes — reapply the label | Only on the next `labeled` event | +| Typical use | "Do this now" commands | State-based routing | +| Supported items | Issues, pull requests, discussions | Issues, pull requests | ### Label Filter Syntax From 446975fb52a38ccb774877b686c9de0cecdac46f Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 15 Mar 2026 21:43:01 +0000 Subject: [PATCH 06/11] docs: document label_command trigger in label-ops and triggers reference Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> --- docs/src/content/docs/reference/triggers.md | 27 +++++++++++++++++++-- 1 file changed, 25 insertions(+), 2 deletions(-) diff --git a/docs/src/content/docs/reference/triggers.md b/docs/src/content/docs/reference/triggers.md index b1fead53ad..f218491fee 100644 --- a/docs/src/content/docs/reference/triggers.md +++ b/docs/src/content/docs/reference/triggers.md @@ -282,9 +282,32 @@ See the [Security Architecture](/gh-aw/introduction/architecture/) for details. The `slash_command:` trigger creates workflows that respond to `/command-name` mentions in issues, pull requests, and comments. See [Command Triggers](/gh-aw/reference/command-triggers/) for complete documentation including event filtering, context text, reactions, and examples. +### Label Command Trigger (`label_command:`) + +The `label_command:` trigger activates a workflow when a specific label is applied to an issue, pull request, or discussion, and **automatically removes that label** so it can be re-applied to re-trigger. This treats a label as a one-shot command rather than a persistent state marker. + +```yaml wrap +# Fires on issues, pull_request, and discussion by default +on: + label_command: deploy + +# Restrict to specific event types +on: + label_command: + name: deploy + events: [pull_request] + +# Shorthand string form +on: "label-command deploy" +``` + +The compiler generates `issues`, `pull_request`, and/or `discussion` events with `types: [labeled]`, adds a `workflow_dispatch` trigger with `item_number` for manual testing, and injects a label removal step in the activation job. The matched label name is exposed as `needs.activation.outputs.label_command`. + +`label_command` can be combined with `slash_command:` — the workflow activates when either condition is met. See [LabelOps](/gh-aw/patterns/label-ops/) for patterns and examples. + ### Label Filtering (`names:`) -Filter issue and pull request triggers by label names using the `names:` field: +Filter issue and pull request triggers by label names using the `names:` field. Unlike `label_command`, the label stays on the item after the workflow runs. ```yaml wrap on: @@ -477,7 +500,7 @@ on: Instead of writing full YAML trigger configurations, you can use natural-language shorthand strings with `on:`. The compiler expands these into standard GitHub Actions trigger syntax and automatically includes `workflow_dispatch` so the workflow can also be run manually. -For label-based shorthands (`on: issue labeled bug`, `on: pull_request labeled needs-review`), see [Label Filtering](#label-filtering-names) above. +For label-based shorthands (`on: issue labeled bug`, `on: pull_request labeled needs-review`), see [Label Filtering](#label-filtering-names) above. For the label-command pattern, see [Label Command Trigger](#label-command-trigger-label_command) above. ### Push and Pull Request From 2e28c383c68f9c600f084951882426de19e8cf73 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 15 Mar 2026 21:49:34 +0000 Subject: [PATCH 07/11] chore: merge main into copilot/update-compiler-label-command-support Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> --- .../agent-performance-analyzer.lock.yml | 1 + .../workflows/agent-persona-explorer.lock.yml | 1 + .github/workflows/audit-workflows.lock.yml | 1 + .github/workflows/cloclo.lock.yml | 1 + .../workflows/daily-cli-tools-tester.lock.yml | 1 + .../workflows/daily-firewall-report.lock.yml | 1 + .../daily-observability-report.lock.yml | 1 + .../daily-rendering-scripts-verifier.lock.yml | 1 + .../daily-safe-output-optimizer.lock.yml | 1 + .github/workflows/deep-report.lock.yml | 1 + .github/workflows/dev-hawk.lock.yml | 1 + .../example-workflow-analyzer.lock.yml | 1 + .github/workflows/mcp-inspector.lock.yml | 1 + .github/workflows/metrics-collector.lock.yml | 1 + .github/workflows/portfolio-analyst.lock.yml | 1 + .../prompt-clustering-analysis.lock.yml | 1 + .github/workflows/python-data-charts.lock.yml | 1 + .github/workflows/q.lock.yml | 1 + .github/workflows/safe-output-health.lock.yml | 1 + .github/workflows/security-review.lock.yml | 1 + .../workflows/smoke-agent-all-merged.lock.yml | 2 +- .github/workflows/smoke-agent-all-merged.md | 2 +- .../workflows/smoke-agent-all-none.lock.yml | 2 +- .github/workflows/smoke-agent-all-none.md | 2 +- .../smoke-agent-public-approved.lock.yml | 2 +- .../workflows/smoke-agent-public-approved.md | 2 +- .../smoke-agent-public-none.lock.yml | 2 +- .github/workflows/smoke-agent-public-none.md | 2 +- .../smoke-agent-scoped-approved.lock.yml | 2 +- .../workflows/smoke-agent-scoped-approved.md | 2 +- .../workflows/smoke-call-workflow.lock.yml | 2 +- .github/workflows/smoke-call-workflow.md | 2 +- .github/workflows/smoke-claude.lock.yml | 3 +- .github/workflows/smoke-claude.md | 2 +- .github/workflows/smoke-codex.lock.yml | 2 +- .github/workflows/smoke-codex.md | 2 +- .github/workflows/smoke-copilot-arm.lock.yml | 3 +- .github/workflows/smoke-copilot-arm.md | 2 +- .github/workflows/smoke-copilot.lock.yml | 3 +- .github/workflows/smoke-copilot.md | 2 +- .../smoke-create-cross-repo-pr.lock.yml | 2 +- .../workflows/smoke-create-cross-repo-pr.md | 2 +- .github/workflows/smoke-gemini.lock.yml | 2 +- .github/workflows/smoke-gemini.md | 2 +- .github/workflows/smoke-multi-pr.lock.yml | 2 +- .github/workflows/smoke-multi-pr.md | 2 +- .github/workflows/smoke-project.lock.yml | 2 +- .github/workflows/smoke-project.md | 2 +- .github/workflows/smoke-temporary-id.lock.yml | 2 +- .github/workflows/smoke-temporary-id.md | 2 +- .github/workflows/smoke-test-tools.lock.yml | 2 +- .github/workflows/smoke-test-tools.md | 2 +- .../smoke-update-cross-repo-pr.lock.yml | 2 +- .../workflows/smoke-update-cross-repo-pr.md | 2 +- .../smoke-workflow-call-with-inputs.lock.yml | 2 +- .../smoke-workflow-call-with-inputs.md | 2 +- .../workflows/smoke-workflow-call.lock.yml | 2 +- .github/workflows/smoke-workflow-call.md | 2 +- .../workflows/static-analysis-report.lock.yml | 1 + .../workflows/workflow-normalizer.lock.yml | 1 + actions/setup/md/agentic_workflows_guide.md | 30 ++++++ pkg/cli/actions_build_command.go | 3 + pkg/cli/add_integration_test.go | 6 +- pkg/cli/includes.go | 4 + pkg/cli/interactive.go | 3 + pkg/console/render.go | 4 + pkg/parser/schemas/main_workflow_schema.json | 9 +- .../allowed_domains_sanitization_test.go | 43 ++++---- pkg/workflow/compiler.go | 6 -- pkg/workflow/compiler_safe_outputs_steps.go | 7 +- .../compiler_safe_outputs_steps_test.go | 4 +- pkg/workflow/compiler_types.go | 39 ++++---- pkg/workflow/compiler_yaml.go | 7 +- pkg/workflow/domains.go | 24 ++--- pkg/workflow/domains_test.go | 48 ++++----- pkg/workflow/mcp_renderer_github.go | 6 ++ pkg/workflow/prompt_constants.go | 1 + pkg/workflow/prompts.go | 15 +++ pkg/workflow/prompts_test.go | 98 +++++++++++++++++++ pkg/workflow/safe_outputs_config.go | 16 +-- pkg/workflow/safe_outputs_validation.go | 54 ++++------ .../smoke-copilot.golden | 1 + pkg/workflow/unified_prompt_step.go | 21 ++-- 83 files changed, 352 insertions(+), 198 deletions(-) create mode 100644 actions/setup/md/agentic_workflows_guide.md diff --git a/.github/workflows/agent-performance-analyzer.lock.yml b/.github/workflows/agent-performance-analyzer.lock.yml index 2a819181f4..39e474e4df 100644 --- a/.github/workflows/agent-performance-analyzer.lock.yml +++ b/.github/workflows/agent-performance-analyzer.lock.yml @@ -130,6 +130,7 @@ jobs: cat "/opt/gh-aw/prompts/xpia.md" cat "/opt/gh-aw/prompts/temp_folder_prompt.md" cat "/opt/gh-aw/prompts/markdown.md" + cat "/opt/gh-aw/prompts/agentic_workflows_guide.md" cat "/opt/gh-aw/prompts/repo_memory_prompt.md" cat "/opt/gh-aw/prompts/safe_outputs_prompt.md" cat << 'GH_AW_PROMPT_EOF' diff --git a/.github/workflows/agent-persona-explorer.lock.yml b/.github/workflows/agent-persona-explorer.lock.yml index ccee4995d5..e4a6947e8b 100644 --- a/.github/workflows/agent-persona-explorer.lock.yml +++ b/.github/workflows/agent-persona-explorer.lock.yml @@ -135,6 +135,7 @@ jobs: cat "/opt/gh-aw/prompts/xpia.md" cat "/opt/gh-aw/prompts/temp_folder_prompt.md" cat "/opt/gh-aw/prompts/markdown.md" + cat "/opt/gh-aw/prompts/agentic_workflows_guide.md" cat "/opt/gh-aw/prompts/cache_memory_prompt.md" cat "/opt/gh-aw/prompts/safe_outputs_prompt.md" cat << 'GH_AW_PROMPT_EOF' diff --git a/.github/workflows/audit-workflows.lock.yml b/.github/workflows/audit-workflows.lock.yml index cb4fbbe3cc..03f9b6cceb 100644 --- a/.github/workflows/audit-workflows.lock.yml +++ b/.github/workflows/audit-workflows.lock.yml @@ -136,6 +136,7 @@ jobs: cat "/opt/gh-aw/prompts/xpia.md" cat "/opt/gh-aw/prompts/temp_folder_prompt.md" cat "/opt/gh-aw/prompts/markdown.md" + cat "/opt/gh-aw/prompts/agentic_workflows_guide.md" cat "/opt/gh-aw/prompts/cache_memory_prompt.md" cat "/opt/gh-aw/prompts/repo_memory_prompt.md" cat "/opt/gh-aw/prompts/safe_outputs_prompt.md" diff --git a/.github/workflows/cloclo.lock.yml b/.github/workflows/cloclo.lock.yml index 131ed4440e..af7e41923d 100644 --- a/.github/workflows/cloclo.lock.yml +++ b/.github/workflows/cloclo.lock.yml @@ -236,6 +236,7 @@ jobs: cat "/opt/gh-aw/prompts/temp_folder_prompt.md" cat "/opt/gh-aw/prompts/markdown.md" cat "/opt/gh-aw/prompts/playwright_prompt.md" + cat "/opt/gh-aw/prompts/agentic_workflows_guide.md" cat "/opt/gh-aw/prompts/cache_memory_prompt.md" cat "/opt/gh-aw/prompts/safe_outputs_prompt.md" cat << 'GH_AW_PROMPT_EOF' diff --git a/.github/workflows/daily-cli-tools-tester.lock.yml b/.github/workflows/daily-cli-tools-tester.lock.yml index 71c0be5ccd..7e9b5a7c98 100644 --- a/.github/workflows/daily-cli-tools-tester.lock.yml +++ b/.github/workflows/daily-cli-tools-tester.lock.yml @@ -133,6 +133,7 @@ jobs: cat "/opt/gh-aw/prompts/xpia.md" cat "/opt/gh-aw/prompts/temp_folder_prompt.md" cat "/opt/gh-aw/prompts/markdown.md" + cat "/opt/gh-aw/prompts/agentic_workflows_guide.md" cat "/opt/gh-aw/prompts/safe_outputs_prompt.md" cat << 'GH_AW_PROMPT_EOF' diff --git a/.github/workflows/daily-firewall-report.lock.yml b/.github/workflows/daily-firewall-report.lock.yml index 67a49718c7..20fe226305 100644 --- a/.github/workflows/daily-firewall-report.lock.yml +++ b/.github/workflows/daily-firewall-report.lock.yml @@ -134,6 +134,7 @@ jobs: cat "/opt/gh-aw/prompts/xpia.md" cat "/opt/gh-aw/prompts/temp_folder_prompt.md" cat "/opt/gh-aw/prompts/markdown.md" + cat "/opt/gh-aw/prompts/agentic_workflows_guide.md" cat "/opt/gh-aw/prompts/cache_memory_prompt.md" cat "/opt/gh-aw/prompts/safe_outputs_prompt.md" cat << 'GH_AW_PROMPT_EOF' diff --git a/.github/workflows/daily-observability-report.lock.yml b/.github/workflows/daily-observability-report.lock.yml index a3eafb8d47..3a1389a1f3 100644 --- a/.github/workflows/daily-observability-report.lock.yml +++ b/.github/workflows/daily-observability-report.lock.yml @@ -136,6 +136,7 @@ jobs: cat "/opt/gh-aw/prompts/xpia.md" cat "/opt/gh-aw/prompts/temp_folder_prompt.md" cat "/opt/gh-aw/prompts/markdown.md" + cat "/opt/gh-aw/prompts/agentic_workflows_guide.md" cat "/opt/gh-aw/prompts/safe_outputs_prompt.md" cat << 'GH_AW_PROMPT_EOF' diff --git a/.github/workflows/daily-rendering-scripts-verifier.lock.yml b/.github/workflows/daily-rendering-scripts-verifier.lock.yml index dcf483d3b6..d99693f6f3 100644 --- a/.github/workflows/daily-rendering-scripts-verifier.lock.yml +++ b/.github/workflows/daily-rendering-scripts-verifier.lock.yml @@ -137,6 +137,7 @@ jobs: cat "/opt/gh-aw/prompts/xpia.md" cat "/opt/gh-aw/prompts/temp_folder_prompt.md" cat "/opt/gh-aw/prompts/markdown.md" + cat "/opt/gh-aw/prompts/agentic_workflows_guide.md" cat "/opt/gh-aw/prompts/cache_memory_prompt.md" cat "/opt/gh-aw/prompts/safe_outputs_prompt.md" cat << 'GH_AW_PROMPT_EOF' diff --git a/.github/workflows/daily-safe-output-optimizer.lock.yml b/.github/workflows/daily-safe-output-optimizer.lock.yml index 393c8b4b68..8f90903235 100644 --- a/.github/workflows/daily-safe-output-optimizer.lock.yml +++ b/.github/workflows/daily-safe-output-optimizer.lock.yml @@ -138,6 +138,7 @@ jobs: cat "/opt/gh-aw/prompts/xpia.md" cat "/opt/gh-aw/prompts/temp_folder_prompt.md" cat "/opt/gh-aw/prompts/markdown.md" + cat "/opt/gh-aw/prompts/agentic_workflows_guide.md" cat "/opt/gh-aw/prompts/cache_memory_prompt.md" cat "/opt/gh-aw/prompts/safe_outputs_prompt.md" cat << 'GH_AW_PROMPT_EOF' diff --git a/.github/workflows/deep-report.lock.yml b/.github/workflows/deep-report.lock.yml index f4b6f88ca5..f6d493350b 100644 --- a/.github/workflows/deep-report.lock.yml +++ b/.github/workflows/deep-report.lock.yml @@ -136,6 +136,7 @@ jobs: cat "/opt/gh-aw/prompts/xpia.md" cat "/opt/gh-aw/prompts/temp_folder_prompt.md" cat "/opt/gh-aw/prompts/markdown.md" + cat "/opt/gh-aw/prompts/agentic_workflows_guide.md" cat "/opt/gh-aw/prompts/cache_memory_prompt.md" cat "/opt/gh-aw/prompts/repo_memory_prompt.md" cat "/opt/gh-aw/prompts/safe_outputs_prompt.md" diff --git a/.github/workflows/dev-hawk.lock.yml b/.github/workflows/dev-hawk.lock.yml index 527ea46e76..441583ebfe 100644 --- a/.github/workflows/dev-hawk.lock.yml +++ b/.github/workflows/dev-hawk.lock.yml @@ -139,6 +139,7 @@ jobs: cat "/opt/gh-aw/prompts/xpia.md" cat "/opt/gh-aw/prompts/temp_folder_prompt.md" cat "/opt/gh-aw/prompts/markdown.md" + cat "/opt/gh-aw/prompts/agentic_workflows_guide.md" cat "/opt/gh-aw/prompts/safe_outputs_prompt.md" cat << 'GH_AW_PROMPT_EOF' diff --git a/.github/workflows/example-workflow-analyzer.lock.yml b/.github/workflows/example-workflow-analyzer.lock.yml index e4770ddae6..06fd8d33e1 100644 --- a/.github/workflows/example-workflow-analyzer.lock.yml +++ b/.github/workflows/example-workflow-analyzer.lock.yml @@ -133,6 +133,7 @@ jobs: cat "/opt/gh-aw/prompts/xpia.md" cat "/opt/gh-aw/prompts/temp_folder_prompt.md" cat "/opt/gh-aw/prompts/markdown.md" + cat "/opt/gh-aw/prompts/agentic_workflows_guide.md" cat "/opt/gh-aw/prompts/safe_outputs_prompt.md" cat << 'GH_AW_PROMPT_EOF' diff --git a/.github/workflows/mcp-inspector.lock.yml b/.github/workflows/mcp-inspector.lock.yml index 7075c7aa19..6d3ddf1ba2 100644 --- a/.github/workflows/mcp-inspector.lock.yml +++ b/.github/workflows/mcp-inspector.lock.yml @@ -148,6 +148,7 @@ jobs: cat "/opt/gh-aw/prompts/xpia.md" cat "/opt/gh-aw/prompts/temp_folder_prompt.md" cat "/opt/gh-aw/prompts/markdown.md" + cat "/opt/gh-aw/prompts/agentic_workflows_guide.md" cat "/opt/gh-aw/prompts/cache_memory_prompt.md" cat "/opt/gh-aw/prompts/safe_outputs_prompt.md" cat << 'GH_AW_PROMPT_EOF' diff --git a/.github/workflows/metrics-collector.lock.yml b/.github/workflows/metrics-collector.lock.yml index c4bc42d432..e4811d63d7 100644 --- a/.github/workflows/metrics-collector.lock.yml +++ b/.github/workflows/metrics-collector.lock.yml @@ -131,6 +131,7 @@ jobs: cat "/opt/gh-aw/prompts/xpia.md" cat "/opt/gh-aw/prompts/temp_folder_prompt.md" cat "/opt/gh-aw/prompts/markdown.md" + cat "/opt/gh-aw/prompts/agentic_workflows_guide.md" cat "/opt/gh-aw/prompts/repo_memory_prompt.md" cat << 'GH_AW_PROMPT_EOF' diff --git a/.github/workflows/portfolio-analyst.lock.yml b/.github/workflows/portfolio-analyst.lock.yml index 31cec5bea8..58b41ade18 100644 --- a/.github/workflows/portfolio-analyst.lock.yml +++ b/.github/workflows/portfolio-analyst.lock.yml @@ -135,6 +135,7 @@ jobs: cat "/opt/gh-aw/prompts/xpia.md" cat "/opt/gh-aw/prompts/temp_folder_prompt.md" cat "/opt/gh-aw/prompts/markdown.md" + cat "/opt/gh-aw/prompts/agentic_workflows_guide.md" cat "/opt/gh-aw/prompts/cache_memory_prompt.md" cat "/opt/gh-aw/prompts/safe_outputs_prompt.md" cat << 'GH_AW_PROMPT_EOF' diff --git a/.github/workflows/prompt-clustering-analysis.lock.yml b/.github/workflows/prompt-clustering-analysis.lock.yml index 0e3d7f5445..b4ff7bbafa 100644 --- a/.github/workflows/prompt-clustering-analysis.lock.yml +++ b/.github/workflows/prompt-clustering-analysis.lock.yml @@ -139,6 +139,7 @@ jobs: cat "/opt/gh-aw/prompts/xpia.md" cat "/opt/gh-aw/prompts/temp_folder_prompt.md" cat "/opt/gh-aw/prompts/markdown.md" + cat "/opt/gh-aw/prompts/agentic_workflows_guide.md" cat "/opt/gh-aw/prompts/cache_memory_prompt.md" cat "/opt/gh-aw/prompts/safe_outputs_prompt.md" cat << 'GH_AW_PROMPT_EOF' diff --git a/.github/workflows/python-data-charts.lock.yml b/.github/workflows/python-data-charts.lock.yml index 3fd3637731..3b610210fa 100644 --- a/.github/workflows/python-data-charts.lock.yml +++ b/.github/workflows/python-data-charts.lock.yml @@ -132,6 +132,7 @@ jobs: cat "/opt/gh-aw/prompts/xpia.md" cat "/opt/gh-aw/prompts/temp_folder_prompt.md" cat "/opt/gh-aw/prompts/markdown.md" + cat "/opt/gh-aw/prompts/agentic_workflows_guide.md" cat "/opt/gh-aw/prompts/cache_memory_prompt.md" cat "/opt/gh-aw/prompts/safe_outputs_prompt.md" cat << 'GH_AW_PROMPT_EOF' diff --git a/.github/workflows/q.lock.yml b/.github/workflows/q.lock.yml index e221c0d5a4..eb5db27119 100644 --- a/.github/workflows/q.lock.yml +++ b/.github/workflows/q.lock.yml @@ -220,6 +220,7 @@ jobs: cat "/opt/gh-aw/prompts/xpia.md" cat "/opt/gh-aw/prompts/temp_folder_prompt.md" cat "/opt/gh-aw/prompts/markdown.md" + cat "/opt/gh-aw/prompts/agentic_workflows_guide.md" cat "/opt/gh-aw/prompts/cache_memory_prompt.md" cat "/opt/gh-aw/prompts/safe_outputs_prompt.md" cat << 'GH_AW_PROMPT_EOF' diff --git a/.github/workflows/safe-output-health.lock.yml b/.github/workflows/safe-output-health.lock.yml index 35f9f52706..5e540c01cc 100644 --- a/.github/workflows/safe-output-health.lock.yml +++ b/.github/workflows/safe-output-health.lock.yml @@ -134,6 +134,7 @@ jobs: cat "/opt/gh-aw/prompts/xpia.md" cat "/opt/gh-aw/prompts/temp_folder_prompt.md" cat "/opt/gh-aw/prompts/markdown.md" + cat "/opt/gh-aw/prompts/agentic_workflows_guide.md" cat "/opt/gh-aw/prompts/cache_memory_prompt.md" cat "/opt/gh-aw/prompts/safe_outputs_prompt.md" cat << 'GH_AW_PROMPT_EOF' diff --git a/.github/workflows/security-review.lock.yml b/.github/workflows/security-review.lock.yml index 90b9598183..bc2236dfa7 100644 --- a/.github/workflows/security-review.lock.yml +++ b/.github/workflows/security-review.lock.yml @@ -169,6 +169,7 @@ jobs: cat "/opt/gh-aw/prompts/xpia.md" cat "/opt/gh-aw/prompts/temp_folder_prompt.md" cat "/opt/gh-aw/prompts/markdown.md" + cat "/opt/gh-aw/prompts/agentic_workflows_guide.md" cat "/opt/gh-aw/prompts/cache_memory_prompt.md" cat "/opt/gh-aw/prompts/safe_outputs_prompt.md" cat << 'GH_AW_PROMPT_EOF' diff --git a/.github/workflows/smoke-agent-all-merged.lock.yml b/.github/workflows/smoke-agent-all-merged.lock.yml index 5e02a44cfc..52d0767709 100644 --- a/.github/workflows/smoke-agent-all-merged.lock.yml +++ b/.github/workflows/smoke-agent-all-merged.lock.yml @@ -23,7 +23,7 @@ # # Guard policy smoke test: repos=all, min-integrity=merged (most restrictive) # -# gh-aw-metadata: {"schema_version":"v2","frontmatter_hash":"4b15096b81758a5494492456bb53b83ac7891ad338b4a0be6eb340f1be5f1b18","strict":true} +# gh-aw-metadata: {"schema_version":"v2","frontmatter_hash":"e3acfb90c523ed41aff2574700873d4ff6fca754fce908743a5b8157de83596a","strict":true} name: "Smoke Agent: all/merged" "on": diff --git a/.github/workflows/smoke-agent-all-merged.md b/.github/workflows/smoke-agent-all-merged.md index a14842d463..f9993df760 100644 --- a/.github/workflows/smoke-agent-all-merged.md +++ b/.github/workflows/smoke-agent-all-merged.md @@ -23,7 +23,7 @@ network: - defaults - github safe-outputs: - allowed-url-domains: [default-redaction] + allowed-domains: [default-safe-outputs] add-comment: hide-older-comments: true max: 2 diff --git a/.github/workflows/smoke-agent-all-none.lock.yml b/.github/workflows/smoke-agent-all-none.lock.yml index 53941d5bb0..30e79b178d 100644 --- a/.github/workflows/smoke-agent-all-none.lock.yml +++ b/.github/workflows/smoke-agent-all-none.lock.yml @@ -23,7 +23,7 @@ # # Guard policy smoke test: repos=all, min-integrity=none (most permissive) # -# gh-aw-metadata: {"schema_version":"v2","frontmatter_hash":"c5cb0ff0be1c9ba981c37d92deb45be8f6e2390a2fc0c666c54615388a0f14d6","strict":true} +# gh-aw-metadata: {"schema_version":"v2","frontmatter_hash":"8456baa1448783534cbadceb33b0a2573cad268e2f9770dbd94a5f8ffa6c5359","strict":true} name: "Smoke Agent: all/none" "on": diff --git a/.github/workflows/smoke-agent-all-none.md b/.github/workflows/smoke-agent-all-none.md index 182540f8ff..a0cc9ae773 100644 --- a/.github/workflows/smoke-agent-all-none.md +++ b/.github/workflows/smoke-agent-all-none.md @@ -23,7 +23,7 @@ network: - defaults - github safe-outputs: - allowed-url-domains: [default-redaction] + allowed-domains: [default-safe-outputs] add-comment: hide-older-comments: true max: 2 diff --git a/.github/workflows/smoke-agent-public-approved.lock.yml b/.github/workflows/smoke-agent-public-approved.lock.yml index 36fe932340..fb916895f3 100644 --- a/.github/workflows/smoke-agent-public-approved.lock.yml +++ b/.github/workflows/smoke-agent-public-approved.lock.yml @@ -23,7 +23,7 @@ # # Smoke test that validates assign-to-agent with the agentic-workflows custom agent # -# gh-aw-metadata: {"schema_version":"v2","frontmatter_hash":"3c582fe0f4bd859be6ae9f91cdda2ebaafa21934283ac26646271a50c05ba1ea","strict":true} +# gh-aw-metadata: {"schema_version":"v2","frontmatter_hash":"3c97796557487cb3f9dfd02804b2c10932fddb5dd95d7c884c33a25e1a9c75d0","strict":true} name: "Smoke Agent: public/approved" "on": diff --git a/.github/workflows/smoke-agent-public-approved.md b/.github/workflows/smoke-agent-public-approved.md index 16f62abb09..3698a4bb6b 100644 --- a/.github/workflows/smoke-agent-public-approved.md +++ b/.github/workflows/smoke-agent-public-approved.md @@ -23,7 +23,7 @@ network: - defaults - github safe-outputs: - allowed-url-domains: [default-redaction] + allowed-domains: [default-safe-outputs] assign-to-agent: target: "*" max: 1 diff --git a/.github/workflows/smoke-agent-public-none.lock.yml b/.github/workflows/smoke-agent-public-none.lock.yml index 346c1d1ef1..cae11bd77b 100644 --- a/.github/workflows/smoke-agent-public-none.lock.yml +++ b/.github/workflows/smoke-agent-public-none.lock.yml @@ -23,7 +23,7 @@ # # Guard policy smoke test: repos=public, min-integrity=none # -# gh-aw-metadata: {"schema_version":"v2","frontmatter_hash":"3318acca97bbfd5d5e552976f57edd2f789657b40f07b73fd961bf014739864c","strict":true} +# gh-aw-metadata: {"schema_version":"v2","frontmatter_hash":"71e286872bc099c4788ba9b318a7ca0a6dd7ce4b1388d03cb2e864cb6f648740","strict":true} name: "Smoke Agent: public/none" "on": diff --git a/.github/workflows/smoke-agent-public-none.md b/.github/workflows/smoke-agent-public-none.md index a524e9ece8..93165819da 100644 --- a/.github/workflows/smoke-agent-public-none.md +++ b/.github/workflows/smoke-agent-public-none.md @@ -23,7 +23,7 @@ network: - defaults - github safe-outputs: - allowed-url-domains: [default-redaction] + allowed-domains: [default-safe-outputs] add-comment: hide-older-comments: true max: 2 diff --git a/.github/workflows/smoke-agent-scoped-approved.lock.yml b/.github/workflows/smoke-agent-scoped-approved.lock.yml index 5fc089a6c7..6aea392251 100644 --- a/.github/workflows/smoke-agent-scoped-approved.lock.yml +++ b/.github/workflows/smoke-agent-scoped-approved.lock.yml @@ -23,7 +23,7 @@ # # Guard policy smoke test: repos=[github/gh-aw, github/*], min-integrity=approved (scoped patterns) # -# gh-aw-metadata: {"schema_version":"v2","frontmatter_hash":"b48c91de1358c6935ee29e21826320d018a9360ab53d2bad9a45ca99939e3ca8","strict":true} +# gh-aw-metadata: {"schema_version":"v2","frontmatter_hash":"8f173dceb0dfef5029dd1b3c2e82e0d19fd91482af0ea15994137ec59dd51a4c","strict":true} name: "Smoke Agent: scoped/approved" "on": diff --git a/.github/workflows/smoke-agent-scoped-approved.md b/.github/workflows/smoke-agent-scoped-approved.md index edfdce6c19..73332c3b71 100644 --- a/.github/workflows/smoke-agent-scoped-approved.md +++ b/.github/workflows/smoke-agent-scoped-approved.md @@ -25,7 +25,7 @@ network: - defaults - github safe-outputs: - allowed-url-domains: [default-redaction] + allowed-domains: [default-safe-outputs] add-comment: hide-older-comments: true max: 2 diff --git a/.github/workflows/smoke-call-workflow.lock.yml b/.github/workflows/smoke-call-workflow.lock.yml index 46c0f5eaf8..9d867686cc 100644 --- a/.github/workflows/smoke-call-workflow.lock.yml +++ b/.github/workflows/smoke-call-workflow.lock.yml @@ -23,7 +23,7 @@ # # Smoke test for the call-workflow safe output - orchestrator that calls a worker via workflow_call at compile-time fan-out # -# gh-aw-metadata: {"schema_version":"v2","frontmatter_hash":"40e319124ecbe26f1e2e8e56c7e02788569d18eeff79c748c7b28b33304b4a50","strict":true} +# gh-aw-metadata: {"schema_version":"v2","frontmatter_hash":"61544e9548c9d83d99c2874329b16fde38e5eed99331c8de79e5eac9093ce3fa","strict":true} name: "Smoke Call Workflow" "on": diff --git a/.github/workflows/smoke-call-workflow.md b/.github/workflows/smoke-call-workflow.md index e56c66d4b4..3cb4fbf500 100644 --- a/.github/workflows/smoke-call-workflow.md +++ b/.github/workflows/smoke-call-workflow.md @@ -17,7 +17,7 @@ network: allowed: - defaults safe-outputs: - allowed-url-domains: [default-redaction] + allowed-domains: [default-safe-outputs] call-workflow: workflows: - smoke-workflow-call diff --git a/.github/workflows/smoke-claude.lock.yml b/.github/workflows/smoke-claude.lock.yml index 9cd37d1b53..107b12a4d6 100644 --- a/.github/workflows/smoke-claude.lock.yml +++ b/.github/workflows/smoke-claude.lock.yml @@ -35,7 +35,7 @@ # # inlined-imports: true # -# gh-aw-metadata: {"schema_version":"v2","frontmatter_hash":"ca7cebdbef133f4a3a2f6c0cddfbdf45fdfd2dc3d9fb2da2a1c9bbba7051f614","strict":true} +# gh-aw-metadata: {"schema_version":"v2","frontmatter_hash":"824ddfaa1c06335001cd4434a5ac6af2749652cef82c56645faa411a4b1c2711","strict":true} name: "Smoke Claude" "on": @@ -194,6 +194,7 @@ jobs: cat "/opt/gh-aw/prompts/temp_folder_prompt.md" cat "/opt/gh-aw/prompts/markdown.md" cat "/opt/gh-aw/prompts/playwright_prompt.md" + cat "/opt/gh-aw/prompts/agentic_workflows_guide.md" cat "/opt/gh-aw/prompts/cache_memory_prompt.md" cat "/opt/gh-aw/prompts/safe_outputs_prompt.md" cat << 'GH_AW_PROMPT_EOF' diff --git a/.github/workflows/smoke-claude.md b/.github/workflows/smoke-claude.md index 611dd0a967..6520cea1dd 100644 --- a/.github/workflows/smoke-claude.md +++ b/.github/workflows/smoke-claude.md @@ -55,7 +55,7 @@ runtimes: go: version: "1.25" safe-outputs: - allowed-url-domains: [default-redaction] + allowed-domains: [default-safe-outputs] add-comment: hide-older-comments: true max: 2 diff --git a/.github/workflows/smoke-codex.lock.yml b/.github/workflows/smoke-codex.lock.yml index 598f9527f7..840e7898c5 100644 --- a/.github/workflows/smoke-codex.lock.yml +++ b/.github/workflows/smoke-codex.lock.yml @@ -28,7 +28,7 @@ # - shared/gh.md # - shared/reporting.md # -# gh-aw-metadata: {"schema_version":"v2","frontmatter_hash":"ad43d1661db52914b927f65775b81caeac5038cd670ff101502b28c508987f20","strict":true} +# gh-aw-metadata: {"schema_version":"v2","frontmatter_hash":"57064b51acff99a53ad08aada3ddf9626cdf25a44c87298d5df01726d62273e5","strict":true} name: "Smoke Codex" "on": diff --git a/.github/workflows/smoke-codex.md b/.github/workflows/smoke-codex.md index bf299ecc8f..2b8f27305b 100644 --- a/.github/workflows/smoke-codex.md +++ b/.github/workflows/smoke-codex.md @@ -41,7 +41,7 @@ sandbox: mcp: container: "ghcr.io/github/gh-aw-mcpg" safe-outputs: - allowed-url-domains: [default-redaction] + allowed-domains: [default-safe-outputs] add-comment: hide-older-comments: true max: 2 diff --git a/.github/workflows/smoke-copilot-arm.lock.yml b/.github/workflows/smoke-copilot-arm.lock.yml index 33d6697868..9ce1782ee7 100644 --- a/.github/workflows/smoke-copilot-arm.lock.yml +++ b/.github/workflows/smoke-copilot-arm.lock.yml @@ -29,7 +29,7 @@ # - shared/github-queries-mcp-script.md # - shared/reporting.md # -# gh-aw-metadata: {"schema_version":"v2","frontmatter_hash":"efc63d49a71c555776c3f68123c684fdaaf063a0fdd43f594f267fc3fc4be53e","strict":true} +# gh-aw-metadata: {"schema_version":"v2","frontmatter_hash":"7d98a942c43c77f4d9757066e1492cdd3c197fb6272337d072c67412dfa07e95","strict":true} name: "Smoke Copilot ARM64" "on": @@ -186,6 +186,7 @@ jobs: cat "/opt/gh-aw/prompts/temp_folder_prompt.md" cat "/opt/gh-aw/prompts/markdown.md" cat "/opt/gh-aw/prompts/playwright_prompt.md" + cat "/opt/gh-aw/prompts/agentic_workflows_guide.md" cat "/opt/gh-aw/prompts/cache_memory_prompt.md" cat "/opt/gh-aw/prompts/safe_outputs_prompt.md" cat << 'GH_AW_PROMPT_EOF' diff --git a/.github/workflows/smoke-copilot-arm.md b/.github/workflows/smoke-copilot-arm.md index 0d3cb2102c..f54a95bd6a 100644 --- a/.github/workflows/smoke-copilot-arm.md +++ b/.github/workflows/smoke-copilot-arm.md @@ -45,7 +45,7 @@ sandbox: mcp: container: "ghcr.io/github/gh-aw-mcpg" safe-outputs: - allowed-url-domains: [default-redaction] + allowed-domains: [default-safe-outputs] add-comment: allowed-repos: ["github/gh-aw"] hide-older-comments: true diff --git a/.github/workflows/smoke-copilot.lock.yml b/.github/workflows/smoke-copilot.lock.yml index 76ff7353b0..e4d9301aa7 100644 --- a/.github/workflows/smoke-copilot.lock.yml +++ b/.github/workflows/smoke-copilot.lock.yml @@ -29,7 +29,7 @@ # - shared/github-queries-mcp-script.md # - shared/reporting.md # -# gh-aw-metadata: {"schema_version":"v2","frontmatter_hash":"b8385d3a5fc381c62fd4658d02d54168dea724b8f222a4e71df570339fa0d084","strict":true} +# gh-aw-metadata: {"schema_version":"v2","frontmatter_hash":"c69fb0db5e338569de880edcb18e606cf17efe9016ab532a0c4f17c1ba71729c","strict":true} name: "Smoke Copilot" "on": @@ -191,6 +191,7 @@ jobs: cat "/opt/gh-aw/prompts/temp_folder_prompt.md" cat "/opt/gh-aw/prompts/markdown.md" cat "/opt/gh-aw/prompts/playwright_prompt.md" + cat "/opt/gh-aw/prompts/agentic_workflows_guide.md" cat "/opt/gh-aw/prompts/cache_memory_prompt.md" cat "/opt/gh-aw/prompts/safe_outputs_prompt.md" cat << 'GH_AW_PROMPT_EOF' diff --git a/.github/workflows/smoke-copilot.md b/.github/workflows/smoke-copilot.md index 70613d145a..6c91699f5e 100644 --- a/.github/workflows/smoke-copilot.md +++ b/.github/workflows/smoke-copilot.md @@ -48,7 +48,7 @@ sandbox: mcp: container: "ghcr.io/github/gh-aw-mcpg" safe-outputs: - allowed-url-domains: [default-redaction] + allowed-domains: [default-safe-outputs] add-comment: allowed-repos: ["github/gh-aw"] hide-older-comments: true diff --git a/.github/workflows/smoke-create-cross-repo-pr.lock.yml b/.github/workflows/smoke-create-cross-repo-pr.lock.yml index f4196223e4..14e3669717 100644 --- a/.github/workflows/smoke-create-cross-repo-pr.lock.yml +++ b/.github/workflows/smoke-create-cross-repo-pr.lock.yml @@ -23,7 +23,7 @@ # # Smoke test validating cross-repo pull request creation in githubnext/gh-aw-side-repo # -# gh-aw-metadata: {"schema_version":"v2","frontmatter_hash":"e2a2075f1ad09ed0c032a09df186a30cdb7c67df6824cac503ae642da6b1d7a7","strict":true} +# gh-aw-metadata: {"schema_version":"v2","frontmatter_hash":"23144cc5cfaff8c43f78aeac9193fc954d2391a4d6fc0207772ebb4127c0ad56","strict":true} name: "Smoke Create Cross-Repo PR" "on": diff --git a/.github/workflows/smoke-create-cross-repo-pr.md b/.github/workflows/smoke-create-cross-repo-pr.md index 416f450cdd..b5df9fba6a 100644 --- a/.github/workflows/smoke-create-cross-repo-pr.md +++ b/.github/workflows/smoke-create-cross-repo-pr.md @@ -32,7 +32,7 @@ tools: github-token: ${{ secrets.GH_AW_SIDE_REPO_PAT }} safe-outputs: - allowed-url-domains: [default-redaction] + allowed-domains: [default-safe-outputs] create-pull-request: target-repo: "githubnext/gh-aw-side-repo" github-token: ${{ secrets.GH_AW_SIDE_REPO_PAT }} diff --git a/.github/workflows/smoke-gemini.lock.yml b/.github/workflows/smoke-gemini.lock.yml index 5959725083..d892866f00 100644 --- a/.github/workflows/smoke-gemini.lock.yml +++ b/.github/workflows/smoke-gemini.lock.yml @@ -28,7 +28,7 @@ # - shared/gh.md # - shared/reporting.md # -# gh-aw-metadata: {"schema_version":"v2","frontmatter_hash":"ac2dc0e8d85ce6faa57b8161338bf3abd8de98b22265eb833db40965b55bda3a","strict":true} +# gh-aw-metadata: {"schema_version":"v2","frontmatter_hash":"130f0cf7df1822b203c39619f6272bb21a12a2e894d0d01a65648278159fa51d","strict":true} name: "Smoke Gemini" "on": diff --git a/.github/workflows/smoke-gemini.md b/.github/workflows/smoke-gemini.md index 532a87b035..02a19abf31 100644 --- a/.github/workflows/smoke-gemini.md +++ b/.github/workflows/smoke-gemini.md @@ -32,7 +32,7 @@ tools: - "*" web-fetch: safe-outputs: - allowed-url-domains: [default-redaction] + allowed-domains: [default-safe-outputs] add-comment: hide-older-comments: true max: 2 diff --git a/.github/workflows/smoke-multi-pr.lock.yml b/.github/workflows/smoke-multi-pr.lock.yml index a6dd807512..513d4e479c 100644 --- a/.github/workflows/smoke-multi-pr.lock.yml +++ b/.github/workflows/smoke-multi-pr.lock.yml @@ -23,7 +23,7 @@ # # Test creating multiple pull requests in a single workflow run # -# gh-aw-metadata: {"schema_version":"v2","frontmatter_hash":"e4b831bcb1c19ca8689f6094b8a56ee28b6300cbfb857297cb03c2cd051ad6c1","strict":true} +# gh-aw-metadata: {"schema_version":"v2","frontmatter_hash":"ee31e52ea131e5257cdee9983e2401eca1666057c51720c5e667f48b6a4de359","strict":true} name: "Smoke Multi PR" "on": diff --git a/.github/workflows/smoke-multi-pr.md b/.github/workflows/smoke-multi-pr.md index be494d3520..af689d422f 100644 --- a/.github/workflows/smoke-multi-pr.md +++ b/.github/workflows/smoke-multi-pr.md @@ -25,7 +25,7 @@ tools: - "echo *" - "printf *" safe-outputs: - allowed-url-domains: [default-redaction] + allowed-domains: [default-safe-outputs] create-pull-request: title-prefix: "[smoke-multi-pr] " if-no-changes: "warn" diff --git a/.github/workflows/smoke-project.lock.yml b/.github/workflows/smoke-project.lock.yml index f282a80b4a..3ff2361448 100644 --- a/.github/workflows/smoke-project.lock.yml +++ b/.github/workflows/smoke-project.lock.yml @@ -23,7 +23,7 @@ # # Smoke Project - Test project operations # -# gh-aw-metadata: {"schema_version":"v2","frontmatter_hash":"537682c51777e746059922e9d8efd9595b4135c3f9ede3270cdf3eb53ff0029d","strict":true} +# gh-aw-metadata: {"schema_version":"v2","frontmatter_hash":"710ec62380b7f4046fc0e531419bf0be666859f6668744e95e249cc359b3d6c8","strict":true} name: "Smoke Project" "on": diff --git a/.github/workflows/smoke-project.md b/.github/workflows/smoke-project.md index e0e9ba6fe3..a9918c53f0 100644 --- a/.github/workflows/smoke-project.md +++ b/.github/workflows/smoke-project.md @@ -24,7 +24,7 @@ tools: bash: - "*" safe-outputs: - allowed-url-domains: [default-redaction] + allowed-domains: [default-safe-outputs] add-comment: hide-older-comments: true max: 2 diff --git a/.github/workflows/smoke-temporary-id.lock.yml b/.github/workflows/smoke-temporary-id.lock.yml index 3c1a933e3f..01df6da921 100644 --- a/.github/workflows/smoke-temporary-id.lock.yml +++ b/.github/workflows/smoke-temporary-id.lock.yml @@ -23,7 +23,7 @@ # # Test temporary ID functionality for issue chaining and cross-references # -# gh-aw-metadata: {"schema_version":"v2","frontmatter_hash":"1407ac440e5791bff6b9fcb37b2df5a8e39ad80c22b3eb9143bfc8a74da7d60a","strict":true} +# gh-aw-metadata: {"schema_version":"v2","frontmatter_hash":"12576301d22af2259a94778128a412fb13a9fa458278f35ffd57e10d58921542","strict":true} name: "Smoke Temporary ID" "on": diff --git a/.github/workflows/smoke-temporary-id.md b/.github/workflows/smoke-temporary-id.md index aed16b05d3..35986171af 100644 --- a/.github/workflows/smoke-temporary-id.md +++ b/.github/workflows/smoke-temporary-id.md @@ -20,7 +20,7 @@ network: - defaults - node safe-outputs: - allowed-url-domains: [default-redaction] + allowed-domains: [default-safe-outputs] create-issue: expires: 2h title-prefix: "[smoke-temporary-id] " diff --git a/.github/workflows/smoke-test-tools.lock.yml b/.github/workflows/smoke-test-tools.lock.yml index 8019892ebc..0eba0a938d 100644 --- a/.github/workflows/smoke-test-tools.lock.yml +++ b/.github/workflows/smoke-test-tools.lock.yml @@ -23,7 +23,7 @@ # # Smoke test to validate common development tools are available in the agent container # -# gh-aw-metadata: {"schema_version":"v2","frontmatter_hash":"ffb326aef953a4e2ab9774e12e0982b5134333724bd729957a90ef83db7079a9","strict":true} +# gh-aw-metadata: {"schema_version":"v2","frontmatter_hash":"5f0e65a120ba50edaf8801625d4f570c66f3428d29b3a6a82a32610833d8d1a5","strict":true} name: "Agent Container Smoke Test" "on": diff --git a/.github/workflows/smoke-test-tools.md b/.github/workflows/smoke-test-tools.md index a9567e03f9..5c72f9614f 100644 --- a/.github/workflows/smoke-test-tools.md +++ b/.github/workflows/smoke-test-tools.md @@ -34,7 +34,7 @@ tools: bash: - "*" safe-outputs: - allowed-url-domains: [default-redaction] + allowed-domains: [default-safe-outputs] add-comment: hide-older-comments: true max: 2 diff --git a/.github/workflows/smoke-update-cross-repo-pr.lock.yml b/.github/workflows/smoke-update-cross-repo-pr.lock.yml index 05b7dc6f07..1a8393b70c 100644 --- a/.github/workflows/smoke-update-cross-repo-pr.lock.yml +++ b/.github/workflows/smoke-update-cross-repo-pr.lock.yml @@ -23,7 +23,7 @@ # # Smoke test validating cross-repo pull request updates in githubnext/gh-aw-side-repo by adding lines from Homer's Odyssey to the README # -# gh-aw-metadata: {"schema_version":"v2","frontmatter_hash":"4077d1a1b4e99803242a555d077279d3cc4448ee14b1548761e0af73c6dcaa9f","strict":true} +# gh-aw-metadata: {"schema_version":"v2","frontmatter_hash":"47ff8252b0357a39438ef0a444222b2ec18a4e2dbfac8706b651d78505b115d9","strict":true} name: "Smoke Update Cross-Repo PR" "on": diff --git a/.github/workflows/smoke-update-cross-repo-pr.md b/.github/workflows/smoke-update-cross-repo-pr.md index 72f7308c9f..b2675b17b6 100644 --- a/.github/workflows/smoke-update-cross-repo-pr.md +++ b/.github/workflows/smoke-update-cross-repo-pr.md @@ -36,7 +36,7 @@ tools: github-token: ${{ secrets.GH_AW_SIDE_REPO_PAT }} safe-outputs: - allowed-url-domains: [default-redaction] + allowed-domains: [default-safe-outputs] create-issue: expires: 2h close-older-issues: true diff --git a/.github/workflows/smoke-workflow-call-with-inputs.lock.yml b/.github/workflows/smoke-workflow-call-with-inputs.lock.yml index 0597c00d2d..386c63808c 100644 --- a/.github/workflows/smoke-workflow-call-with-inputs.lock.yml +++ b/.github/workflows/smoke-workflow-call-with-inputs.lock.yml @@ -23,7 +23,7 @@ # # Reusable workflow with inputs - used to test that multiple callers don't clash on artifact names # -# gh-aw-metadata: {"schema_version":"v2","frontmatter_hash":"771740f24e223b8d34da36ea8e78936a7e124c69e9b98c1e4c8ff4b5ddaebe00","strict":true} +# gh-aw-metadata: {"schema_version":"v2","frontmatter_hash":"70659d13c51a9ae641724fcb962c32d078d6734c237660a62fdc5ccc0e7c999d","strict":true} name: "Smoke Workflow Call with Inputs" "on": diff --git a/.github/workflows/smoke-workflow-call-with-inputs.md b/.github/workflows/smoke-workflow-call-with-inputs.md index dc97e41c7f..b508cdd2b6 100644 --- a/.github/workflows/smoke-workflow-call-with-inputs.md +++ b/.github/workflows/smoke-workflow-call-with-inputs.md @@ -30,7 +30,7 @@ tools: - "echo *" - "date" safe-outputs: - allowed-url-domains: [default-redaction] + allowed-domains: [default-safe-outputs] noop: timeout-minutes: 5 --- diff --git a/.github/workflows/smoke-workflow-call.lock.yml b/.github/workflows/smoke-workflow-call.lock.yml index 2aeeea337b..de8f1f45c3 100644 --- a/.github/workflows/smoke-workflow-call.lock.yml +++ b/.github/workflows/smoke-workflow-call.lock.yml @@ -23,7 +23,7 @@ # # Reusable workflow to validate checkout from fork works correctly in workflow_call context # -# gh-aw-metadata: {"schema_version":"v2","frontmatter_hash":"972c331610f4c5939b8d9a0ab62a3a7f666c2882b2207acbf7e49f55c2b3a980","strict":true} +# gh-aw-metadata: {"schema_version":"v2","frontmatter_hash":"ec8e06e54f77696dc841d99cf0dbe8391ab81ac495ec1ebb806afab7287c77f1","strict":true} name: "Smoke Workflow Call" "on": diff --git a/.github/workflows/smoke-workflow-call.md b/.github/workflows/smoke-workflow-call.md index 412ef02a3f..0d0029b44a 100644 --- a/.github/workflows/smoke-workflow-call.md +++ b/.github/workflows/smoke-workflow-call.md @@ -35,7 +35,7 @@ tools: - "git remote *" - "echo *" safe-outputs: - allowed-url-domains: [default-redaction] + allowed-domains: [default-safe-outputs] add-comment: hide-older-comments: true max: 1 diff --git a/.github/workflows/static-analysis-report.lock.yml b/.github/workflows/static-analysis-report.lock.yml index e61c3fa151..eff471b928 100644 --- a/.github/workflows/static-analysis-report.lock.yml +++ b/.github/workflows/static-analysis-report.lock.yml @@ -133,6 +133,7 @@ jobs: cat "/opt/gh-aw/prompts/xpia.md" cat "/opt/gh-aw/prompts/temp_folder_prompt.md" cat "/opt/gh-aw/prompts/markdown.md" + cat "/opt/gh-aw/prompts/agentic_workflows_guide.md" cat "/opt/gh-aw/prompts/cache_memory_prompt.md" cat "/opt/gh-aw/prompts/safe_outputs_prompt.md" cat << 'GH_AW_PROMPT_EOF' diff --git a/.github/workflows/workflow-normalizer.lock.yml b/.github/workflows/workflow-normalizer.lock.yml index 98679ab54e..7a96d3b03b 100644 --- a/.github/workflows/workflow-normalizer.lock.yml +++ b/.github/workflows/workflow-normalizer.lock.yml @@ -133,6 +133,7 @@ jobs: cat "/opt/gh-aw/prompts/xpia.md" cat "/opt/gh-aw/prompts/temp_folder_prompt.md" cat "/opt/gh-aw/prompts/markdown.md" + cat "/opt/gh-aw/prompts/agentic_workflows_guide.md" cat "/opt/gh-aw/prompts/safe_outputs_prompt.md" cat << 'GH_AW_PROMPT_EOF' diff --git a/actions/setup/md/agentic_workflows_guide.md b/actions/setup/md/agentic_workflows_guide.md new file mode 100644 index 0000000000..d573b55854 --- /dev/null +++ b/actions/setup/md/agentic_workflows_guide.md @@ -0,0 +1,30 @@ + +## Using the agentic-workflows MCP Server + +**⚠️ CRITICAL**: The `status`, `logs`, `audit`, and `compile` operations are MCP server tools, +NOT shell commands. Do NOT run `gh aw` directly — it is not authenticated in this context. +Do not attempt to download or build the `gh aw` extension. If the MCP server fails, give up. +Call all operations as MCP tools with JSON parameters. + +- Run the `status` tool to verify configuration and list all workflows +- Use the `logs` tool to download run logs (saves to `/tmp/gh-aw/aw-mcp/logs/`) +- Use the `audit` tool with a run ID or URL to investigate specific runs + +### Tool Parameters + +#### `status` — Verify MCP server configuration and list workflows + +#### `logs` — Download workflow run logs +- `workflow_name`: filter to a specific workflow (leave empty for all) +- `count`: number of runs (default: 100) +- `start_date`: filter runs after this date (YYYY-MM-DD or relative like `-1d`, `-7d`, `-30d`) +- `end_date`: filter runs before this date +- `engine`: filter by AI engine (`copilot`, `claude`, `codex`) +- `branch`: filter by branch name +- `firewall` / `no_firewall`: filter by firewall status +- `after_run_id` / `before_run_id`: paginate by run database ID +- Logs are saved to `/tmp/gh-aw/aw-mcp/logs/` + +#### `audit` — Inspect a specific run +- `run_id_or_url`: numeric run ID, run URL, job URL, or job URL with step anchor + diff --git a/pkg/cli/actions_build_command.go b/pkg/cli/actions_build_command.go index f535858d4f..d7ce774959 100644 --- a/pkg/cli/actions_build_command.go +++ b/pkg/cli/actions_build_command.go @@ -145,6 +145,7 @@ func getActionDirectories(actionsDir string) ([]string, error) { } sort.Strings(dirs) + actionsBuildLog.Printf("Found %d action directories in %s", len(dirs), actionsDir) return dirs, nil } @@ -162,6 +163,7 @@ func getActionDirectories(actionsDir string) ([]string, error) { // // This follows the principle that domain-specific validation belongs in domain files. func validateActionYml(actionPath string) error { + actionsBuildLog.Printf("Validating action.yml: path=%s", actionPath) ymlPath := filepath.Join(actionPath, "action.yml") if _, err := os.Stat(ymlPath); os.IsNotExist(err) { @@ -299,6 +301,7 @@ func isCompositeAction(actionPath string) (bool, error) { // These files are manually edited and committed to git. They are NOT synced to pkg/workflow/ // At runtime, setup.sh copies these files to /tmp/gh-aw/actions for workflow execution. func buildSetupAction(actionsDir, actionName string) error { + actionsBuildLog.Printf("Building setup action: actionsDir=%s, actionName=%s", actionsDir, actionName) actionPath := filepath.Join(actionsDir, actionName) jsDir := filepath.Join(actionPath, "js") shDir := filepath.Join(actionPath, "sh") diff --git a/pkg/cli/add_integration_test.go b/pkg/cli/add_integration_test.go index 53b28a7e29..915e433b38 100644 --- a/pkg/cli/add_integration_test.go +++ b/pkg/cli/add_integration_test.go @@ -978,7 +978,11 @@ func TestAddWorkflowWithDispatchWorkflowFromSharedImport(t *testing.T) { // frontmatter. haiku-printer lives as haiku-printer.yml (a plain GitHub Actions // workflow). The fetcher falls back to .yml when .md is 404, so both the main // workflow and the dispatch-workflow dependency are written to disk. - workflowSpec := "github/gh-aw/.github/workflows/smoke-copilot.md@main" + // + // Note: pinned to a specific commit SHA from the branch that renamed + // allowed-url-domains → allowed-domains (schema change). Update to @main once + // that change has been merged. + workflowSpec := "github/gh-aw/.github/workflows/smoke-copilot.md@c93eec8" cmd := exec.Command(setup.binaryPath, "add", workflowSpec, "--verbose") cmd.Dir = setup.tempDir diff --git a/pkg/cli/includes.go b/pkg/cli/includes.go index 905e2cfaa4..8656ca37b4 100644 --- a/pkg/cli/includes.go +++ b/pkg/cli/includes.go @@ -119,6 +119,8 @@ func fetchAndSaveRemoteFrontmatterImports(content string, spec *WorkflowSpec, ta return nil } + remoteWorkflowLog.Printf("Fetching frontmatter imports for workflow: repo=%s, path=%s", spec.RepoSlug, spec.WorkflowPath) + parts := strings.SplitN(spec.RepoSlug, "/", 2) if len(parts) != 2 { return nil @@ -189,6 +191,8 @@ func fetchFrontmatterImportsRecursive(content, owner, repo, ref, currentBaseDir, return } + remoteWorkflowLog.Printf("Processing %d frontmatter imports recursively: owner=%s, repo=%s, ref=%s", len(importPaths), owner, repo, ref) + // Pre-compute the absolute target directory once for path-traversal boundary checks. absTargetDir, err := filepath.Abs(targetDir) if err != nil { diff --git a/pkg/cli/interactive.go b/pkg/cli/interactive.go index e25e0f4f74..485e06b887 100644 --- a/pkg/cli/interactive.go +++ b/pkg/cli/interactive.go @@ -141,6 +141,7 @@ func (b *InteractiveWorkflowBuilder) promptForConfiguration() error { // Pre-detect network access based on repo contents detectedNetworks := detectNetworkFromRepo() + interactiveLog.Printf("Pre-detected networks from repo: %v", detectedNetworks) // Prepare network options networkOptions := []huh.Option[string]{ @@ -231,6 +232,8 @@ func (b *InteractiveWorkflowBuilder) promptForConfiguration() error { b.Tools = selectedTools b.SafeOutputs = selectedOutputs + interactiveLog.Printf("User configuration selected: trigger=%s, engine=%s, tools=%v, safe_outputs=%v", b.Trigger, b.Engine, selectedTools, selectedOutputs) + return nil } diff --git a/pkg/console/render.go b/pkg/console/render.go index 5faa8baeaa..9f92849feb 100644 --- a/pkg/console/render.go +++ b/pkg/console/render.go @@ -154,6 +154,8 @@ func renderSlice(val reflect.Value, title string, output *strings.Builder, depth return } + renderLog.Printf("Rendering slice: title=%s, length=%d, element_type=%s", title, val.Len(), val.Type().Elem().Name()) + // Print title without FormatInfoMessage styling if title != "" { if depth == 0 { @@ -208,6 +210,8 @@ func renderMap(val reflect.Value, title string, output *strings.Builder, depth i // buildTableConfig builds a TableConfig from a slice of structs func buildTableConfig(val reflect.Value, title string) TableConfig { + renderLog.Printf("Building table config: title=%s, elements=%d", title, val.Len()) + config := TableConfig{ Title: "", } diff --git a/pkg/parser/schemas/main_workflow_schema.json b/pkg/parser/schemas/main_workflow_schema.json index c89b9e4b3c..21014dfdae 100644 --- a/pkg/parser/schemas/main_workflow_schema.json +++ b/pkg/parser/schemas/main_workflow_schema.json @@ -4216,7 +4216,7 @@ "properties": { "allowed-domains": { "type": "array", - "description": "List of allowed domains for URI filtering in AI workflow output. URLs from other domains will be replaced with '(redacted)' for security.", + "description": "List of allowed domains for URL redaction in safe output handlers. Supports ecosystem identifiers (e.g., \"python\", \"node\", \"default-safe-outputs\") like network.allowed. These domains are unioned with the engine defaults and network.allowed when computing the final allowed domain set. localhost and github.com are always included.", "items": { "type": "string" } @@ -7491,13 +7491,6 @@ } ] ] - }, - "allowed-url-domains": { - "type": "array", - "description": "Additional allowed domains for URL redaction in safe output handlers. Supports the same ecosystem identifiers as network.allowed (e.g., \"python\", \"node\", \"dev-tools\"). These domains are unioned with the engine defaults and network.allowed when computing the final allowed domain set. localhost and github.com are always included.", - "items": { - "type": "string" - } } }, "additionalProperties": false diff --git a/pkg/workflow/allowed_domains_sanitization_test.go b/pkg/workflow/allowed_domains_sanitization_test.go index ce53617eb3..98e0bbe5c7 100644 --- a/pkg/workflow/allowed_domains_sanitization_test.go +++ b/pkg/workflow/allowed_domains_sanitization_test.go @@ -238,9 +238,9 @@ Test workflow with ecosystem identifiers. } } -// TestManualAllowedDomainsHasPriority tests that manually configured allowed-domains -// takes precedence over network configuration -func TestManualAllowedDomainsHasPriority(t *testing.T) { +// TestManualAllowedDomainsUnionWithNetworkConfig tests that manually configured allowed-domains +// unions with network configuration (not overrides it) +func TestManualAllowedDomainsUnionWithNetworkConfig(t *testing.T) { tests := []struct { name string workflow string @@ -248,7 +248,7 @@ func TestManualAllowedDomainsHasPriority(t *testing.T) { unexpectedDomain string }{ { - name: "Manual allowed-domains overrides network config", + name: "Manual allowed-domains unions with network config", workflow: `--- on: push permissions: @@ -270,14 +270,15 @@ safe-outputs: # Test Workflow -Test that manual allowed-domains takes precedence. +Test that manual allowed-domains unions with network config. `, expectedDomains: []string{ "manual-domain.com", "override.org", + "example.com", // from network.allowed - still present (union) }, - // Network domains and Copilot defaults should NOT be included - unexpectedDomain: "example.com", + // No domain should be absent + unexpectedDomain: "", }, { name: "Empty allowed-domains uses network config", @@ -459,16 +460,16 @@ func TestComputeAllowedDomainsForSanitization(t *testing.T) { } } -// TestAllowedURLDomainsUnionWithNetworkConfig tests that safe-outputs.allowed-url-domains +// TestAllowedDomainsUnionWithNetworkConfig tests that safe-outputs.allowed-domains // is unioned with network.allowed and always includes localhost and github.com -func TestAllowedURLDomainsUnionWithNetworkConfig(t *testing.T) { +func TestAllowedDomainsUnionWithNetworkConfig(t *testing.T) { tests := []struct { name string workflow string expectedDomains []string }{ { - name: "allowed-url-domains unioned with Copilot defaults and network config", + name: "allowed-domains unioned with Copilot defaults and network config", workflow: `--- on: push permissions: @@ -481,16 +482,16 @@ network: - example.com safe-outputs: create-issue: - allowed-url-domains: + allowed-domains: - extra-domain.com --- # Test Workflow -Test allowed-url-domains union with network config. +Test allowed-domains union with network config. `, expectedDomains: []string{ - "extra-domain.com", // from allowed-url-domains + "extra-domain.com", // from allowed-domains "example.com", // from network.allowed "api.github.com", // Copilot default "localhost", // always included @@ -498,7 +499,7 @@ Test allowed-url-domains union with network config. }, }, { - name: "allowed-url-domains supports ecosystem identifiers", + name: "allowed-domains supports ecosystem identifiers", workflow: `--- on: push permissions: @@ -508,14 +509,14 @@ engine: copilot strict: false safe-outputs: create-issue: - allowed-url-domains: + allowed-domains: - dev-tools - python --- # Test Workflow -Test allowed-url-domains with ecosystem identifiers. +Test allowed-domains with ecosystem identifiers. `, expectedDomains: []string{ "codecov.io", // from dev-tools ecosystem @@ -526,7 +527,7 @@ Test allowed-url-domains with ecosystem identifiers. }, }, { - name: "allowed-url-domains does not override network config", + name: "allowed-domains does not override network config", workflow: `--- on: push permissions: @@ -539,16 +540,16 @@ network: - network-domain.com safe-outputs: create-issue: - allowed-url-domains: + allowed-domains: - url-domain.com --- # Test Workflow -Test that allowed-url-domains does not override network config. +Test that allowed-domains does not override network config. `, expectedDomains: []string{ - "url-domain.com", // from allowed-url-domains + "url-domain.com", // from allowed-domains "network-domain.com", // from network.allowed - still present (union) "api.github.com", // Copilot default "localhost", // always included @@ -558,7 +559,7 @@ Test that allowed-url-domains does not override network config. for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - tmpDir := testutil.TempDir(t, "allowed-url-domains-test") + tmpDir := testutil.TempDir(t, "allowed-domains-test") testFile := filepath.Join(tmpDir, "test-workflow.md") if err := os.WriteFile(testFile, []byte(tt.workflow), 0644); err != nil { t.Fatal(err) diff --git a/pkg/workflow/compiler.go b/pkg/workflow/compiler.go index 38210f900d..7e3723dc35 100644 --- a/pkg/workflow/compiler.go +++ b/pkg/workflow/compiler.go @@ -155,12 +155,6 @@ func (c *Compiler) validateWorkflowData(workflowData *WorkflowData, markdownPath return formatCompilerError(markdownPath, "error", err.Error(), err) } - // Validate safe-outputs allowed-url-domains configuration - log.Printf("Validating safe-outputs allowed-url-domains") - if err := c.validateSafeOutputsAllowedURLDomains(workflowData.SafeOutputs); err != nil { - return formatCompilerError(markdownPath, "error", err.Error(), err) - } - // Emit warnings for push-to-pull-request-branch misconfiguration log.Printf("Validating push-to-pull-request-branch configuration") c.validatePushToPullRequestBranchWarnings(workflowData.SafeOutputs, workflowData.CheckoutConfigs) diff --git a/pkg/workflow/compiler_safe_outputs_steps.go b/pkg/workflow/compiler_safe_outputs_steps.go index 64c6c42936..a218fab6b3 100644 --- a/pkg/workflow/compiler_safe_outputs_steps.go +++ b/pkg/workflow/compiler_safe_outputs_steps.go @@ -4,7 +4,6 @@ import ( "encoding/json" "fmt" "sort" - "strings" "github.com/github/gh-aw/pkg/logger" "github.com/github/gh-aw/pkg/stringutil" @@ -320,10 +319,8 @@ func (c *Compiler) buildHandlerManagerStep(data *WorkflowData) []string { // default GitHub domains, causing user-configured allowed domains to be redacted. var domainsStr string if data.SafeOutputs != nil && len(data.SafeOutputs.AllowedDomains) > 0 { - domainsStr = strings.Join(data.SafeOutputs.AllowedDomains, ",") - } else if data.SafeOutputs != nil && len(data.SafeOutputs.AllowedURLDomains) > 0 { - // allowed-url-domains: additional domains unioned with engine/network base set - domainsStr = c.computeAllowedURLDomainsForSanitization(data) + // allowed-domains: additional domains unioned with engine/network base set; supports ecosystem identifiers + domainsStr = c.computeExpandedAllowedDomainsForSanitization(data) } else { domainsStr = c.computeAllowedDomainsForSanitization(data) } diff --git a/pkg/workflow/compiler_safe_outputs_steps_test.go b/pkg/workflow/compiler_safe_outputs_steps_test.go index 3e70e9d29d..f77b24f2cc 100644 --- a/pkg/workflow/compiler_safe_outputs_steps_test.go +++ b/pkg/workflow/compiler_safe_outputs_steps_test.go @@ -508,7 +508,9 @@ func TestBuildHandlerManagerStep(t *testing.T) { AddComments: &AddCommentsConfig{}, }, checkContains: []string{ - "GH_AW_ALLOWED_DOMAINS: \"docs.example.com,api.example.com\"", + "GH_AW_ALLOWED_DOMAINS:", + "docs.example.com", + "api.example.com", "GITHUB_SERVER_URL: ${{ github.server_url }}", "GITHUB_API_URL: ${{ github.api_url }}", }, diff --git a/pkg/workflow/compiler_types.go b/pkg/workflow/compiler_types.go index 32ed866b6c..e267e91054 100644 --- a/pkg/workflow/compiler_types.go +++ b/pkg/workflow/compiler_types.go @@ -480,26 +480,25 @@ type SafeOutputsConfig struct { ThreatDetection *ThreatDetectionConfig `yaml:"threat-detection,omitempty"` // Threat detection configuration Jobs map[string]*SafeJobConfig `yaml:"jobs,omitempty"` // Safe-jobs configuration (moved from top-level) GitHubApp *GitHubAppConfig `yaml:"github-app,omitempty"` // GitHub App credentials for token minting - AllowedDomains []string `yaml:"allowed-domains,omitempty"` - AllowedURLDomains []string `yaml:"allowed-url-domains,omitempty"` // Additional allowed domains for URL redaction, unioned with network.allowed; supports ecosystem identifiers - AllowGitHubReferences []string `yaml:"allowed-github-references,omitempty"` // Allowed repositories for GitHub references (e.g., ["repo", "org/repo2"]) - Staged bool `yaml:"staged,omitempty"` // If true, emit step summary messages instead of making GitHub API calls - Env map[string]string `yaml:"env,omitempty"` // Environment variables to pass to safe output jobs - GitHubToken string `yaml:"github-token,omitempty"` // GitHub token for safe output jobs - MaximumPatchSize int `yaml:"max-patch-size,omitempty"` // Maximum allowed patch size in KB (defaults to 1024) - RunsOn string `yaml:"runs-on,omitempty"` // Runner configuration for safe-outputs jobs - Messages *SafeOutputMessagesConfig `yaml:"messages,omitempty"` // Custom message templates for footer and notifications - Mentions *MentionsConfig `yaml:"mentions,omitempty"` // Configuration for @mention filtering in safe outputs - Footer *bool `yaml:"footer,omitempty"` // Global footer control - when false, omits visible footer from all safe outputs (XML markers still included) - GroupReports bool `yaml:"group-reports,omitempty"` // If true, create parent "Failed runs" issue for agent failures (default: false) - ReportFailureAsIssue *bool `yaml:"report-failure-as-issue,omitempty"` // If false, disables creating failure tracking issues when workflows fail (default: true) - FailureIssueRepo string `yaml:"failure-issue-repo,omitempty"` // Repository to create failure issues in (format: "owner/repo"), defaults to current repo - MaxBotMentions *string `yaml:"max-bot-mentions,omitempty"` // Maximum bot trigger references (e.g. 'fixes #123') allowed before filtering. Default: 10. Supports integer or GitHub Actions expression. - Steps []any `yaml:"steps,omitempty"` // User-provided steps injected after setup/checkout and before safe-output code - IDToken *string `yaml:"id-token,omitempty"` // Override id-token permission: "write" to force-add, "none" to disable auto-detection - ConcurrencyGroup string `yaml:"concurrency-group,omitempty"` // Concurrency group for the safe-outputs job (cancel-in-progress is always false) - Environment string `yaml:"environment,omitempty"` // Override the GitHub deployment environment for the safe-outputs job (defaults to the top-level environment: field) - AutoInjectedCreateIssue bool `yaml:"-"` // Internal: true when create-issues was automatically injected by the compiler (not user-configured) + AllowedDomains []string `yaml:"allowed-domains,omitempty"` // Allowed domains for URL redaction, unioned with network.allowed; supports ecosystem identifiers + AllowGitHubReferences []string `yaml:"allowed-github-references,omitempty"` // Allowed repositories for GitHub references (e.g., ["repo", "org/repo2"]) + Staged bool `yaml:"staged,omitempty"` // If true, emit step summary messages instead of making GitHub API calls + Env map[string]string `yaml:"env,omitempty"` // Environment variables to pass to safe output jobs + GitHubToken string `yaml:"github-token,omitempty"` // GitHub token for safe output jobs + MaximumPatchSize int `yaml:"max-patch-size,omitempty"` // Maximum allowed patch size in KB (defaults to 1024) + RunsOn string `yaml:"runs-on,omitempty"` // Runner configuration for safe-outputs jobs + Messages *SafeOutputMessagesConfig `yaml:"messages,omitempty"` // Custom message templates for footer and notifications + Mentions *MentionsConfig `yaml:"mentions,omitempty"` // Configuration for @mention filtering in safe outputs + Footer *bool `yaml:"footer,omitempty"` // Global footer control - when false, omits visible footer from all safe outputs (XML markers still included) + GroupReports bool `yaml:"group-reports,omitempty"` // If true, create parent "Failed runs" issue for agent failures (default: false) + ReportFailureAsIssue *bool `yaml:"report-failure-as-issue,omitempty"` // If false, disables creating failure tracking issues when workflows fail (default: true) + FailureIssueRepo string `yaml:"failure-issue-repo,omitempty"` // Repository to create failure issues in (format: "owner/repo"), defaults to current repo + MaxBotMentions *string `yaml:"max-bot-mentions,omitempty"` // Maximum bot trigger references (e.g. 'fixes #123') allowed before filtering. Default: 10. Supports integer or GitHub Actions expression. + Steps []any `yaml:"steps,omitempty"` // User-provided steps injected after setup/checkout and before safe-output code + IDToken *string `yaml:"id-token,omitempty"` // Override id-token permission: "write" to force-add, "none" to disable auto-detection + ConcurrencyGroup string `yaml:"concurrency-group,omitempty"` // Concurrency group for the safe-outputs job (cancel-in-progress is always false) + Environment string `yaml:"environment,omitempty"` // Override the GitHub deployment environment for the safe-outputs job (defaults to the top-level environment: field) + AutoInjectedCreateIssue bool `yaml:"-"` // Internal: true when create-issues was automatically injected by the compiler (not user-configured) } // SafeOutputMessagesConfig holds custom message templates for safe-output footer and notification messages diff --git a/pkg/workflow/compiler_yaml.go b/pkg/workflow/compiler_yaml.go index 63a0f79829..33da8f9fa4 100644 --- a/pkg/workflow/compiler_yaml.go +++ b/pkg/workflow/compiler_yaml.go @@ -693,11 +693,8 @@ func (c *Compiler) generateOutputCollectionStep(yaml *strings.Builder, data *Wor // Use manually configured domains if available, otherwise compute from network configuration var domainsStr string if data.SafeOutputs != nil && len(data.SafeOutputs.AllowedDomains) > 0 { - // Use manually configured allowed domains (legacy override behavior) - domainsStr = strings.Join(data.SafeOutputs.AllowedDomains, ",") - } else if data.SafeOutputs != nil && len(data.SafeOutputs.AllowedURLDomains) > 0 { - // allowed-url-domains: additional domains unioned with engine/network base set - domainsStr = c.computeAllowedURLDomainsForSanitization(data) + // allowed-domains: additional domains unioned with engine/network base set; supports ecosystem identifiers + domainsStr = c.computeExpandedAllowedDomainsForSanitization(data) } else { // Fall back to computing from network configuration (same as firewall) domainsStr = c.computeAllowedDomainsForSanitization(data) diff --git a/pkg/workflow/domains.go b/pkg/workflow/domains.go index aab1702e3a..38f68a55fb 100644 --- a/pkg/workflow/domains.go +++ b/pkg/workflow/domains.go @@ -132,10 +132,10 @@ func init() { // component ecosystems. These are resolved at lookup time, so they stay in sync with // any future changes to the component ecosystems. var compoundEcosystems = map[string][]string{ - // default-redaction: the recommended baseline for URL redaction in safe-outputs. + // default-safe-outputs: the recommended baseline for URL redaction in safe-outputs. // Covers common infrastructure certificate/OCSP hosts (via "defaults") plus popular // developer-tool and CI/CD service domains (via "dev-tools"). - "default-redaction": {"defaults", "dev-tools"}, + "default-safe-outputs": {"defaults", "dev-tools"}, } // getEcosystemDomains returns the domains for a given ecosystem category. @@ -361,7 +361,7 @@ var ecosystemPriority = []string{ "swift", "terraform", "zig", - "default-redaction", // compound: defaults + dev-tools + "default-safe-outputs", // compound: defaults + dev-tools } // GetDomainEcosystem returns the ecosystem identifier for a given domain, or empty string if not found. @@ -669,10 +669,10 @@ func (c *Compiler) computeAllowedDomainsForSanitization(data *WorkflowData) stri } } -// expandAllowedURLDomains expands a list of domain entries (which may include ecosystem +// expandAllowedDomains expands a list of domain entries (which may include ecosystem // identifiers like "python", "node", "dev-tools") into a deduplicated, sorted list of // concrete domain strings. This uses the same expansion logic as network.allowed. -func expandAllowedURLDomains(entries []string) []string { +func expandAllowedDomains(entries []string) []string { domainMap := make(map[string]bool) for _, entry := range entries { ecosystemDomains := getEcosystemDomains(entry) @@ -692,11 +692,11 @@ func expandAllowedURLDomains(entries []string) []string { return result } -// computeAllowedURLDomainsForSanitization computes the allowed domains for URL sanitization, -// unioning the engine/network base set with the safe-outputs.allowed-url-domains entries. +// computeExpandedAllowedDomainsForSanitization computes the allowed domains for URL sanitization, +// unioning the engine/network base set with the safe-outputs.allowed-domains entries. // It always includes "localhost" and "github.com" in the result. -// The allowed-url-domains entries support ecosystem identifiers (same syntax as network.allowed). -func (c *Compiler) computeAllowedURLDomainsForSanitization(data *WorkflowData) string { +// The allowed-domains entries support ecosystem identifiers (same syntax as network.allowed). +func (c *Compiler) computeExpandedAllowedDomainsForSanitization(data *WorkflowData) string { // Start from the base set (engine defaults + network.allowed + tools + runtimes) base := c.computeAllowedDomainsForSanitization(data) @@ -712,9 +712,9 @@ func (c *Compiler) computeAllowedURLDomainsForSanitization(data *WorkflowData) s } } - // Union with allowed-url-domains (expanded) - if data.SafeOutputs != nil && len(data.SafeOutputs.AllowedURLDomains) > 0 { - for _, d := range expandAllowedURLDomains(data.SafeOutputs.AllowedURLDomains) { + // Union with allowed-domains (expanded) + if data.SafeOutputs != nil && len(data.SafeOutputs.AllowedDomains) > 0 { + for _, d := range expandAllowedDomains(data.SafeOutputs.AllowedDomains) { domainMap[d] = true } } diff --git a/pkg/workflow/domains_test.go b/pkg/workflow/domains_test.go index 2833f38b1e..f84bd2c8fc 100644 --- a/pkg/workflow/domains_test.go +++ b/pkg/workflow/domains_test.go @@ -893,10 +893,10 @@ func TestGetCodexAllowedDomainsWithToolsAndRuntimes(t *testing.T) { }) } -// TestExpandAllowedURLDomains tests the expandAllowedURLDomains function -func TestExpandAllowedURLDomains(t *testing.T) { +// TestExpandAllowedDomains tests the expandAllowedDomains function +func TestExpandAllowedDomains(t *testing.T) { t.Run("plain domains are returned as-is", func(t *testing.T) { - result := expandAllowedURLDomains([]string{"example.com", "test.org"}) + result := expandAllowedDomains([]string{"example.com", "test.org"}) if !strings.Contains(strings.Join(result, ","), "example.com") { t.Error("Expected example.com in result") } @@ -906,7 +906,7 @@ func TestExpandAllowedURLDomains(t *testing.T) { }) t.Run("ecosystem identifiers are expanded", func(t *testing.T) { - result := expandAllowedURLDomains([]string{"python"}) + result := expandAllowedDomains([]string{"python"}) joined := strings.Join(result, ",") if !strings.Contains(joined, "pypi.org") { t.Error("Expected pypi.org from python ecosystem in result") @@ -914,7 +914,7 @@ func TestExpandAllowedURLDomains(t *testing.T) { }) t.Run("dev-tools ecosystem is expanded", func(t *testing.T) { - result := expandAllowedURLDomains([]string{"dev-tools"}) + result := expandAllowedDomains([]string{"dev-tools"}) joined := strings.Join(result, ",") if !strings.Contains(joined, "codecov.io") { t.Error("Expected codecov.io from dev-tools ecosystem in result") @@ -925,7 +925,7 @@ func TestExpandAllowedURLDomains(t *testing.T) { }) t.Run("mixed plain domains and ecosystem identifiers", func(t *testing.T) { - result := expandAllowedURLDomains([]string{"example.com", "python"}) + result := expandAllowedDomains([]string{"example.com", "python"}) joined := strings.Join(result, ",") if !strings.Contains(joined, "example.com") { t.Error("Expected example.com in result") @@ -936,16 +936,16 @@ func TestExpandAllowedURLDomains(t *testing.T) { }) t.Run("empty input returns empty result", func(t *testing.T) { - result := expandAllowedURLDomains([]string{}) + result := expandAllowedDomains([]string{}) if len(result) != 0 { t.Errorf("Expected empty result, got %v", result) } }) } -// TestComputeAllowedURLDomainsForSanitization tests that allowed-url-domains are unioned with +// TestComputeExpandedAllowedDomainsForSanitization tests that allowed-domains are unioned with // the engine/network base set and always includes localhost and github.com -func TestComputeAllowedURLDomainsForSanitization(t *testing.T) { +func TestComputeExpandedAllowedDomainsForSanitization(t *testing.T) { compiler := NewCompiler() t.Run("unions with engine base set", func(t *testing.T) { @@ -955,10 +955,10 @@ func TestComputeAllowedURLDomainsForSanitization(t *testing.T) { Allowed: []string{"example.com"}, }, SafeOutputs: &SafeOutputsConfig{ - AllowedURLDomains: []string{"extra-domain.com"}, + AllowedDomains: []string{"extra-domain.com"}, }, } - result := compiler.computeAllowedURLDomainsForSanitization(data) + result := compiler.computeExpandedAllowedDomainsForSanitization(data) if !strings.Contains(result, "extra-domain.com") { t.Error("Expected extra-domain.com in result") } @@ -974,12 +974,12 @@ func TestComputeAllowedURLDomainsForSanitization(t *testing.T) { data := &WorkflowData{ EngineConfig: &EngineConfig{ID: "copilot"}, SafeOutputs: &SafeOutputsConfig{ - AllowedURLDomains: []string{"extra-domain.com"}, + AllowedDomains: []string{"extra-domain.com"}, }, } - result := compiler.computeAllowedURLDomainsForSanitization(data) + result := compiler.computeExpandedAllowedDomainsForSanitization(data) if !strings.Contains(result, "localhost") { - t.Error("Expected localhost to always be in allowed-url-domains result") + t.Error("Expected localhost to always be in allowed-domains result") } }) @@ -987,12 +987,12 @@ func TestComputeAllowedURLDomainsForSanitization(t *testing.T) { data := &WorkflowData{ EngineConfig: &EngineConfig{ID: "codex"}, SafeOutputs: &SafeOutputsConfig{ - AllowedURLDomains: []string{"extra-domain.com"}, + AllowedDomains: []string{"extra-domain.com"}, }, } - result := compiler.computeAllowedURLDomainsForSanitization(data) + result := compiler.computeExpandedAllowedDomainsForSanitization(data) if !strings.Contains(result, "github.com") { - t.Error("Expected github.com to always be in allowed-url-domains result") + t.Error("Expected github.com to always be in allowed-domains result") } }) @@ -1000,10 +1000,10 @@ func TestComputeAllowedURLDomainsForSanitization(t *testing.T) { data := &WorkflowData{ EngineConfig: &EngineConfig{ID: "copilot"}, SafeOutputs: &SafeOutputsConfig{ - AllowedURLDomains: []string{"python", "dev-tools"}, + AllowedDomains: []string{"python", "dev-tools"}, }, } - result := compiler.computeAllowedURLDomainsForSanitization(data) + result := compiler.computeExpandedAllowedDomainsForSanitization(data) if !strings.Contains(result, "pypi.org") { t.Error("Expected pypi.org from python ecosystem in result") } @@ -1013,10 +1013,10 @@ func TestComputeAllowedURLDomainsForSanitization(t *testing.T) { }) } -// TestDefaultRedactionEcosystem tests that the default-redaction compound ecosystem +// TestDefaultSafeOutputsEcosystem tests that the default-safe-outputs compound ecosystem // correctly expands to the union of defaults + dev-tools -func TestDefaultRedactionEcosystem(t *testing.T) { - result := expandAllowedURLDomains([]string{"default-redaction"}) +func TestDefaultSafeOutputsEcosystem(t *testing.T) { + result := expandAllowedDomains([]string{"default-safe-outputs"}) joined := strings.Join(result, ",") // From defaults ecosystem @@ -1026,12 +1026,12 @@ func TestDefaultRedactionEcosystem(t *testing.T) { for _, d := range append(defaultsSamples, devToolsSamples...) { if !strings.Contains(joined, d) { - t.Errorf("Expected domain %q from default-redaction ecosystem in result", d) + t.Errorf("Expected domain %q from default-safe-outputs ecosystem in result", d) } } // Should include both defaults and dev-tools (at least 34+21 = 55 domains) if len(result) < 50 { - t.Errorf("Expected at least 50 domains in default-redaction, got %d", len(result)) + t.Errorf("Expected at least 50 domains in default-safe-outputs, got %d", len(result)) } } diff --git a/pkg/workflow/mcp_renderer_github.go b/pkg/workflow/mcp_renderer_github.go index 2fffd20ae8..7b15b6c9c0 100644 --- a/pkg/workflow/mcp_renderer_github.go +++ b/pkg/workflow/mcp_renderer_github.go @@ -98,6 +98,8 @@ func (r *MCPConfigRendererUnified) renderGitHubTOML(yaml *strings.Builder, githu lockdown := getGitHubLockdown(githubTool) toolsets := getGitHubToolsets(githubTool) + mcpRendererLog.Printf("Rendering GitHub MCP TOML: type=%s, read_only=%t, lockdown=%t, toolsets=%s", githubType, readOnly, lockdown, toolsets) + yaml.WriteString(" \n") yaml.WriteString(" [mcp_servers.github]\n") @@ -220,6 +222,8 @@ func (r *MCPConfigRendererUnified) renderGitHubTOML(yaml *strings.Builder, githu // - yaml: The string builder for YAML output // - options: GitHub MCP Docker rendering options func RenderGitHubMCPDockerConfig(yaml *strings.Builder, options GitHubMCPDockerOptions) { + mcpRendererLog.Printf("Rendering GitHub MCP Docker config: image=%s, read_only=%t, lockdown=%t", options.DockerImageVersion, options.ReadOnly, options.Lockdown) + // Add type field if needed (Copilot requires this, Claude doesn't) // Per MCP Gateway Specification v1.0.0 section 4.1.2, use "stdio" for containerized servers if options.IncludeTypeField { @@ -328,6 +332,8 @@ func RenderGitHubMCPDockerConfig(yaml *strings.Builder, options GitHubMCPDockerO // - yaml: The string builder for YAML output // - options: GitHub MCP remote rendering options func RenderGitHubMCPRemoteConfig(yaml *strings.Builder, options GitHubMCPRemoteOptions) { + mcpRendererLog.Printf("Rendering GitHub MCP remote config: read_only=%t, lockdown=%t, toolsets=%s", options.ReadOnly, options.Lockdown, options.Toolsets) + // Remote mode - use hosted GitHub MCP server yaml.WriteString(" \"type\": \"http\",\n") yaml.WriteString(" \"url\": \"https://api.githubcopilot.com/mcp/\",\n") diff --git a/pkg/workflow/prompt_constants.go b/pkg/workflow/prompt_constants.go index 2f9d9c39be..6c26bf782e 100644 --- a/pkg/workflow/prompt_constants.go +++ b/pkg/workflow/prompt_constants.go @@ -18,6 +18,7 @@ const ( safeOutputsCreatePRFile = "safe_outputs_create_pull_request.md" safeOutputsPushToBranchFile = "safe_outputs_push_to_pr_branch.md" safeOutputsAutoCreateIssueFile = "safe_outputs_auto_create_issue.md" + agenticWorkflowsGuideFile = "agentic_workflows_guide.md" ) // GitHub context prompt is kept embedded because it contains GitHub Actions expressions diff --git a/pkg/workflow/prompts.go b/pkg/workflow/prompts.go index 82e3732f7d..00b9e7b132 100644 --- a/pkg/workflow/prompts.go +++ b/pkg/workflow/prompts.go @@ -29,6 +29,21 @@ func hasPlaywrightTool(parsedTools *Tools) bool { return hasPlaywright } +// ============================================================================ +// Tool Prompts - Agentic Workflows +// ============================================================================ + +// hasAgenticWorkflowsTool checks if the agentic workflows tool is enabled in the tools configuration +func hasAgenticWorkflowsTool(parsedTools *Tools) bool { + if parsedTools == nil { + log.Print("Checking for agentic-workflows tool: no parsed tools provided") + return false + } + hasAgenticWorkflows := parsedTools.AgenticWorkflows != nil + log.Printf("Agentic-workflows tool enabled: %v", hasAgenticWorkflows) + return hasAgenticWorkflows +} + // ============================================================================ // PR Context Prompts // ============================================================================ diff --git a/pkg/workflow/prompts_test.go b/pkg/workflow/prompts_test.go index f560c671d4..4b439b85e3 100644 --- a/pkg/workflow/prompts_test.go +++ b/pkg/workflow/prompts_test.go @@ -750,3 +750,101 @@ This is a test workflow without contents read permission. t.Logf("Successfully verified PR context instructions are NOT included without contents permission") } + +// ============================================================================ +// Agentic Workflows Guide Prompt Tests +// ============================================================================ + +func TestAgenticWorkflowsGuideIncludedWhenEnabled(t *testing.T) { + tmpDir, err := os.MkdirTemp("", "gh-aw-agentic-guide-test-*") + if err != nil { + t.Fatalf("Failed to create temp dir: %v", err) + } + defer os.RemoveAll(tmpDir) + + testFile := filepath.Join(tmpDir, "test-workflow.md") + testContent := `--- +on: schedule daily +engine: claude +tools: + agentic-workflows: +permissions: + actions: read +--- + +# Test Workflow with Agentic Workflows + +This workflow uses the agentic-workflows MCP server. +` + + if err := os.WriteFile(testFile, []byte(testContent), 0644); err != nil { + t.Fatalf("Failed to create test workflow: %v", err) + } + + compiler := NewCompiler() + if err := compiler.CompileWorkflow(testFile); err != nil { + t.Fatalf("Failed to compile workflow: %v", err) + } + + lockFile := stringutil.MarkdownToLockFile(testFile) + lockContent, err := os.ReadFile(lockFile) + if err != nil { + t.Fatalf("Failed to read generated lock file: %v", err) + } + + lockStr := string(lockContent) + + if !strings.Contains(lockStr, "- name: Create prompt with built-in context") { + t.Error("Expected 'Create prompt with built-in context' step in generated workflow") + } + + if !strings.Contains(lockStr, "cat \"/opt/gh-aw/prompts/agentic_workflows_guide.md\"") { + t.Error("Expected cat command for agentic_workflows_guide.md in generated workflow") + } + + t.Logf("Successfully verified agentic-workflows guide is included when agentic-workflows tool is enabled") +} + +func TestAgenticWorkflowsGuideNotIncludedWhenDisabled(t *testing.T) { + tmpDir, err := os.MkdirTemp("", "gh-aw-no-agentic-guide-test-*") + if err != nil { + t.Fatalf("Failed to create temp dir: %v", err) + } + defer os.RemoveAll(tmpDir) + + testFile := filepath.Join(tmpDir, "test-workflow.md") + testContent := `--- +on: push +engine: codex +tools: + github: +--- + +# Test Workflow without Agentic Workflows + +This workflow does not use the agentic-workflows MCP server. +` + + if err := os.WriteFile(testFile, []byte(testContent), 0644); err != nil { + t.Fatalf("Failed to create test workflow: %v", err) + } + + compiler := NewCompiler() + if err := compiler.CompileWorkflow(testFile); err != nil { + t.Fatalf("Failed to compile workflow: %v", err) + } + + lockFile := stringutil.MarkdownToLockFile(testFile) + lockContent, err := os.ReadFile(lockFile) + if err != nil { + t.Fatalf("Failed to read generated lock file: %v", err) + } + + lockStr := string(lockContent) + + if strings.Contains(lockStr, "agentic_workflows_guide.md") { + t.Error("Did not expect 'agentic_workflows_guide.md' reference in workflow without agentic-workflows tool") + } + + t.Logf("Successfully verified agentic-workflows guide is NOT included when agentic-workflows tool is disabled") +} diff --git a/pkg/workflow/safe_outputs_config.go b/pkg/workflow/safe_outputs_config.go index 560a6e6cb9..61ce26df1c 100644 --- a/pkg/workflow/safe_outputs_config.go +++ b/pkg/workflow/safe_outputs_config.go @@ -170,7 +170,7 @@ func (c *Compiler) extractSafeOutputsConfig(frontmatter map[string]any) *SafeOut config.AutofixCodeScanningAlert = autofixCodeScanningAlertConfig } - // Parse allowed-domains configuration + // Parse allowed-domains configuration (additional domains, unioned with network.allowed; supports ecosystem identifiers) if allowedDomains, exists := outputMap["allowed-domains"]; exists { if domainsArray, ok := allowedDomains.([]any); ok { var domainStrings []string @@ -184,20 +184,6 @@ func (c *Compiler) extractSafeOutputsConfig(frontmatter map[string]any) *SafeOut } } - // Parse allowed-url-domains configuration (additional domains, unioned with network.allowed) - if allowedURLDomains, exists := outputMap["allowed-url-domains"]; exists { - if domainsArray, ok := allowedURLDomains.([]any); ok { - var domainStrings []string - for _, domain := range domainsArray { - if domainStr, ok := domain.(string); ok { - domainStrings = append(domainStrings, domainStr) - } - } - config.AllowedURLDomains = domainStrings - safeOutputsConfigLog.Printf("Configured allowed-url-domains with %d domain(s)", len(domainStrings)) - } - } - // Parse allowed-github-references configuration if allowGitHubRefs, exists := outputMap["allowed-github-references"]; exists { if refsArray, ok := allowGitHubRefs.([]any); ok { diff --git a/pkg/workflow/safe_outputs_validation.go b/pkg/workflow/safe_outputs_validation.go index 9820777d7c..cb466de065 100644 --- a/pkg/workflow/safe_outputs_validation.go +++ b/pkg/workflow/safe_outputs_validation.go @@ -21,6 +21,12 @@ func (c *Compiler) validateNetworkAllowedDomains(network *NetworkPermissions) er collector := NewErrorCollector(c.failFast) for i, domain := range network.Allowed { + // "*" means allow all traffic - skip validation + if domain == "*" { + safeOutputsDomainsValidationLog.Print("Skipping allow-all wildcard '*'") + continue + } + // Skip ecosystem identifiers - they don't need domain pattern validation if isEcosystemIdentifier(domain) { safeOutputsDomainsValidationLog.Printf("Skipping ecosystem identifier: %s", domain) @@ -44,11 +50,15 @@ func (c *Compiler) validateNetworkAllowedDomains(network *NetworkPermissions) er return nil } +// isEcosystemIdentifierPattern matches valid ecosystem identifiers like "defaults", "node", "dev-tools" +var isEcosystemIdentifierPattern = regexp.MustCompile(`^[a-z][a-z0-9-]*$`) + // isEcosystemIdentifier checks if a domain string is actually an ecosystem identifier func isEcosystemIdentifier(domain string) bool { - // Ecosystem identifiers don't contain dots and don't have protocol prefixes - // They are simple identifiers like "defaults", "node", "python", etc. - return !strings.Contains(domain, ".") && !strings.Contains(domain, "://") + // Ecosystem identifiers are simple lowercase alphanumeric identifiers with optional hyphens + // like "defaults", "node", "python", "dev-tools", "default-safe-outputs". + // They don't contain dots, protocol prefixes, spaces, wildcards, or other special characters. + return isEcosystemIdentifierPattern.MatchString(domain) } // domainPattern validates domain patterns including wildcards @@ -61,7 +71,8 @@ func isEcosystemIdentifier(domain string) bool { // - Empty or malformed domains var domainPattern = regexp.MustCompile(`^(\*\.)?[a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(\.[a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$`) -// validateSafeOutputsAllowedDomains validates the allowed-domains configuration in safe-outputs +// validateSafeOutputsAllowedDomains validates the allowed-domains configuration in safe-outputs. +// Supports ecosystem identifiers (e.g., "python", "node", "default-safe-outputs") like network.allowed. func (c *Compiler) validateSafeOutputsAllowedDomains(config *SafeOutputsConfig) error { if config == nil || len(config.AllowedDomains) == 0 { return nil @@ -72,35 +83,6 @@ func (c *Compiler) validateSafeOutputsAllowedDomains(config *SafeOutputsConfig) collector := NewErrorCollector(c.failFast) for i, domain := range config.AllowedDomains { - if err := validateDomainPattern(domain); err != nil { - wrappedErr := fmt.Errorf("safe-outputs.allowed-domains[%d]: %w", i, err) - if returnErr := collector.Add(wrappedErr); returnErr != nil { - return returnErr // Fail-fast mode - } - } - } - - if err := collector.Error(); err != nil { - safeOutputsDomainsValidationLog.Printf("Safe outputs allowed domains validation failed: %v", err) - return err - } - - safeOutputsDomainsValidationLog.Print("Safe outputs allowed domains validation passed") - return nil -} - -// validateSafeOutputsAllowedURLDomains validates the allowed-url-domains configuration in safe-outputs. -// Supports ecosystem identifiers (e.g., "python", "node") like network.allowed. -func (c *Compiler) validateSafeOutputsAllowedURLDomains(config *SafeOutputsConfig) error { - if config == nil || len(config.AllowedURLDomains) == 0 { - return nil - } - - safeOutputsDomainsValidationLog.Printf("Validating %d allowed-url-domains", len(config.AllowedURLDomains)) - - collector := NewErrorCollector(c.failFast) - - for i, domain := range config.AllowedURLDomains { // Skip ecosystem identifiers - they don't need domain pattern validation if isEcosystemIdentifier(domain) { safeOutputsDomainsValidationLog.Printf("Skipping ecosystem identifier: %s", domain) @@ -108,7 +90,7 @@ func (c *Compiler) validateSafeOutputsAllowedURLDomains(config *SafeOutputsConfi } if err := validateDomainPattern(domain); err != nil { - wrappedErr := fmt.Errorf("safe-outputs.allowed-url-domains[%d]: %w", i, err) + wrappedErr := fmt.Errorf("safe-outputs.allowed-domains[%d]: %w", i, err) if returnErr := collector.Add(wrappedErr); returnErr != nil { return returnErr // Fail-fast mode } @@ -116,11 +98,11 @@ func (c *Compiler) validateSafeOutputsAllowedURLDomains(config *SafeOutputsConfi } if err := collector.Error(); err != nil { - safeOutputsDomainsValidationLog.Printf("Safe outputs allowed-url-domains validation failed: %v", err) + safeOutputsDomainsValidationLog.Printf("Safe outputs allowed domains validation failed: %v", err) return err } - safeOutputsDomainsValidationLog.Print("Safe outputs allowed-url-domains validation passed") + safeOutputsDomainsValidationLog.Print("Safe outputs allowed domains validation passed") return nil } diff --git a/pkg/workflow/testdata/wasm_golden/TestWasmGolden_CompileFixtures/smoke-copilot.golden b/pkg/workflow/testdata/wasm_golden/TestWasmGolden_CompileFixtures/smoke-copilot.golden index 8dc75d2585..fc2df53889 100644 --- a/pkg/workflow/testdata/wasm_golden/TestWasmGolden_CompileFixtures/smoke-copilot.golden +++ b/pkg/workflow/testdata/wasm_golden/TestWasmGolden_CompileFixtures/smoke-copilot.golden @@ -125,6 +125,7 @@ jobs: cat "/opt/gh-aw/prompts/temp_folder_prompt.md" cat "/opt/gh-aw/prompts/markdown.md" cat "/opt/gh-aw/prompts/playwright_prompt.md" + cat "/opt/gh-aw/prompts/agentic_workflows_guide.md" cat << 'GH_AW_PROMPT_EOF' The following GitHub context information is available for this workflow: diff --git a/pkg/workflow/unified_prompt_step.go b/pkg/workflow/unified_prompt_step.go index d045c04634..5626859e3e 100644 --- a/pkg/workflow/unified_prompt_step.go +++ b/pkg/workflow/unified_prompt_step.go @@ -255,7 +255,16 @@ func (c *Compiler) collectPromptSections(data *WorkflowData) []PromptSection { }) } - // 4. Trial mode note (if in trial mode) + // 4. Agentic Workflows MCP guide (if agentic-workflows tool is enabled) + if hasAgenticWorkflowsTool(data.ParsedTools) { + unifiedPromptLog.Print("Adding agentic-workflows guide section") + sections = append(sections, PromptSection{ + Content: agenticWorkflowsGuideFile, + IsFile: true, + }) + } + + // 5. Trial mode note (if in trial mode) if c.trialMode { unifiedPromptLog.Print("Adding trial mode section") trialContent := fmt.Sprintf("## Note\nThis workflow is running in directory $GITHUB_WORKSPACE, but that directory actually contains the contents of the repository '%s'.", c.trialLogicalRepoSlug) @@ -265,7 +274,7 @@ func (c *Compiler) collectPromptSections(data *WorkflowData) []PromptSection { }) } - // 5. Cache memory instructions (if enabled) + // 6. Cache memory instructions (if enabled) if data.CacheMemoryConfig != nil && len(data.CacheMemoryConfig.Caches) > 0 { unifiedPromptLog.Printf("Adding cache memory section: caches=%d", len(data.CacheMemoryConfig.Caches)) section := buildCacheMemoryPromptSection(data.CacheMemoryConfig) @@ -274,7 +283,7 @@ func (c *Compiler) collectPromptSections(data *WorkflowData) []PromptSection { } } - // 6. Repo memory instructions (if enabled) + // 7. Repo memory instructions (if enabled) if data.RepoMemoryConfig != nil && len(data.RepoMemoryConfig.Memories) > 0 { unifiedPromptLog.Printf("Adding repo memory section: memories=%d", len(data.RepoMemoryConfig.Memories)) section := buildRepoMemoryPromptSection(data.RepoMemoryConfig) @@ -283,7 +292,7 @@ func (c *Compiler) collectPromptSections(data *WorkflowData) []PromptSection { } } - // 7. Safe outputs instructions (if enabled) + // 8. Safe outputs instructions (if enabled) if HasSafeOutputsEnabled(data.SafeOutputs) { unifiedPromptLog.Print("Adding safe outputs section") // Static intro from file (gh CLI warning, temporary ID rules, noop note) @@ -294,7 +303,7 @@ func (c *Compiler) collectPromptSections(data *WorkflowData) []PromptSection { // Per-tool sections: opening tag + tools list (inline), tool instruction files, closing tag sections = append(sections, buildSafeOutputsSections(data.SafeOutputs)...) } - // 8. GitHub context (if GitHub tool is enabled) + // 9. GitHub context (if GitHub tool is enabled) if hasGitHubTool(data.ParsedTools) { unifiedPromptLog.Print("Adding GitHub context section") @@ -333,7 +342,7 @@ func (c *Compiler) collectPromptSections(data *WorkflowData) []PromptSection { } } - // 9. PR context (if comment-related triggers and checkout is needed) + // 10. PR context (if comment-related triggers and checkout is needed) hasCommentTriggers := c.hasCommentRelatedTriggers(data) needsCheckout := c.shouldAddCheckoutStep(data) permParser := NewPermissionsParser(data.Permissions) From 000bada21ce3ad1e6dead4a4a7a4f8fae6ce968a Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 15 Mar 2026 22:31:35 +0000 Subject: [PATCH 08/11] fix: treat 404 as non-error in remove_trigger_label (label already removed) Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> --- .github/workflows/smoke-copilot.lock.yml | 2 +- actions/setup/js/remove_trigger_label.cjs | 10 ++++++++-- 2 files changed, 9 insertions(+), 3 deletions(-) diff --git a/.github/workflows/smoke-copilot.lock.yml b/.github/workflows/smoke-copilot.lock.yml index ac45828075..e4d9301aa7 100644 --- a/.github/workflows/smoke-copilot.lock.yml +++ b/.github/workflows/smoke-copilot.lock.yml @@ -29,7 +29,7 @@ # - shared/github-queries-mcp-script.md # - shared/reporting.md # -# gh-aw-metadata: {"schema_version":"v2","frontmatter_hash":"1a7fa2c830c582eef2c1ca858d020772b9efc949615721d692484047ab127162","strict":true} +# gh-aw-metadata: {"schema_version":"v2","frontmatter_hash":"c69fb0db5e338569de880edcb18e606cf17efe9016ab532a0c4f17c1ba71729c","strict":true} name: "Smoke Copilot" "on": diff --git a/actions/setup/js/remove_trigger_label.cjs b/actions/setup/js/remove_trigger_label.cjs index 876c3e43b7..b4346cb203 100644 --- a/actions/setup/js/remove_trigger_label.cjs +++ b/actions/setup/js/remove_trigger_label.cjs @@ -123,8 +123,14 @@ async function main() { } } catch (error) { // Non-fatal: log a warning but do not fail the step. - // The label may have already been removed or may not be present. - core.warning(`${ERR_API}: Failed to remove label '${triggerLabel}': ${getErrorMessage(error)}`); + // A 404 status means the label is no longer present on the item (e.g., another concurrent + // workflow run already removed it), which is an expected outcome in multi-workflow setups. + const status = /** @type {any} */ error?.status; + if (status === 404) { + core.info(`Label '${triggerLabel}' is no longer present on the item – already removed by another run.`); + } else { + core.warning(`${ERR_API}: Failed to remove label '${triggerLabel}': ${getErrorMessage(error)}`); + } } core.setOutput("label_name", triggerLabel); From be2a502c7f412bf692c031ec307e15065f7adbda Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 15 Mar 2026 23:01:24 +0000 Subject: [PATCH 09/11] fix: address code review comments on label_command trigger - Fix GH_AW_LABEL_NAMES env var: use single-quoted YAML string ('["deploy"]') instead of %q-formatted string to pass raw JSON array to remove_trigger_label.cjs - Add issues:write and discussions:write permissions to activation job when label_command is configured so label removal calls succeed - Set HasDispatchItemNumber=true when label_command injects workflow_dispatch, enabling inputs.item_number fallbacks in expression mappings and concurrency keys - Improve LabelCommandOtherEvents merge: union types arrays and preserve non-types fields instead of silently dropping overlapping keys - Return error from applyDefaults when yaml.Marshal fails for label-command events - Add conflict validation in compiler_safe_outputs: reject issues/pull_request/ discussion triggers with non-label types alongside label_command - Fix TestLabelCommandNoClashWithExistingLabelTrigger: use actual issues:labeled trigger to test real key-clash merge; add TestLabelCommandConflictWithNonLabelTrigger Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> --- .github/workflows/cloclo.lock.yml | 2 +- .github/workflows/smoke-copilot.lock.yml | 2 +- pkg/workflow/compiler_activation_job.go | 9 ++++- pkg/workflow/compiler_safe_outputs.go | 11 ++++++ pkg/workflow/label_command_test.go | 43 ++++++++++++++++++---- pkg/workflow/tools.go | 46 +++++++++++++++++------- 6 files changed, 91 insertions(+), 22 deletions(-) diff --git a/.github/workflows/cloclo.lock.yml b/.github/workflows/cloclo.lock.yml index af7e41923d..1cd2d38757 100644 --- a/.github/workflows/cloclo.lock.yml +++ b/.github/workflows/cloclo.lock.yml @@ -201,7 +201,7 @@ jobs: id: remove_trigger_label uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 env: - GH_AW_LABEL_NAMES: "[\"cloclo\"]" + GH_AW_LABEL_NAMES: '["cloclo"]' with: script: | const { setupGlobals } = require('/opt/gh-aw/actions/setup_globals.cjs'); diff --git a/.github/workflows/smoke-copilot.lock.yml b/.github/workflows/smoke-copilot.lock.yml index e4d9301aa7..bdd884a2ed 100644 --- a/.github/workflows/smoke-copilot.lock.yml +++ b/.github/workflows/smoke-copilot.lock.yml @@ -161,7 +161,7 @@ jobs: id: remove_trigger_label uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 env: - GH_AW_LABEL_NAMES: "[\"smoke\"]" + GH_AW_LABEL_NAMES: '["smoke"]' with: script: | const { setupGlobals } = require('/opt/gh-aw/actions/setup_globals.cjs'); diff --git a/pkg/workflow/compiler_activation_job.go b/pkg/workflow/compiler_activation_job.go index 046bb89e60..885947e497 100644 --- a/pkg/workflow/compiler_activation_job.go +++ b/pkg/workflow/compiler_activation_job.go @@ -300,7 +300,7 @@ func (c *Compiler) buildActivationJob(data *WorkflowData, preActivationJobCreate if err != nil { return nil, fmt.Errorf("failed to marshal label-command names: %w", err) } - steps = append(steps, fmt.Sprintf(" GH_AW_LABEL_NAMES: %q\n", string(labelNamesJSON))) + steps = append(steps, fmt.Sprintf(" GH_AW_LABEL_NAMES: '%s'\n", string(labelNamesJSON))) steps = append(steps, " with:\n") steps = append(steps, " script: |\n") steps = append(steps, generateGitHubScriptWithRequire("remove_trigger_label.cjs")) @@ -451,6 +451,13 @@ func (c *Compiler) buildActivationJob(data *WorkflowData, preActivationJobCreate permsMap[PermissionIssues] = PermissionWrite } + // Add write permissions for label removal when label_command is configured. + // Labels on issues/PRs require issues:write; discussion labels require discussions:write. + if len(data.LabelCommand) > 0 { + permsMap[PermissionIssues] = PermissionWrite + permsMap[PermissionDiscussions] = PermissionWrite + } + perms := NewPermissionsFromMap(permsMap) permissions := perms.RenderToYAML() diff --git a/pkg/workflow/compiler_safe_outputs.go b/pkg/workflow/compiler_safe_outputs.go index 6e49a13256..55eab625fa 100644 --- a/pkg/workflow/compiler_safe_outputs.go +++ b/pkg/workflow/compiler_safe_outputs.go @@ -153,6 +153,17 @@ func (c *Compiler) parseOnSection(frontmatter map[string]any, workflowData *Work baseName := strings.TrimSuffix(filepath.Base(markdownPath), ".md") workflowData.LabelCommand = []string{baseName} } + // Validate: existing issues/pull_request/discussion triggers that have non-label types + // would be silently overridden by the label_command generation. Require label-only types + // (labeled/unlabeled) so the merge is deterministic and user config is not lost. + labelConflictingEvents := []string{"issues", "pull_request", "discussion"} + for _, eventName := range labelConflictingEvents { + if eventValue, hasConflict := onMap[eventName]; hasConflict { + if !parser.IsLabelOnlyEvent(eventValue) { + return fmt.Errorf("cannot use 'label_command' with '%s' trigger (non-label types); use only labeled/unlabeled types or remove this trigger", eventName) + } + } + } // Clear the On field so applyDefaults will handle label-command trigger generation workflowData.On = "" } diff --git a/pkg/workflow/label_command_test.go b/pkg/workflow/label_command_test.go index ac23adef95..dcb5df80f4 100644 --- a/pkg/workflow/label_command_test.go +++ b/pkg/workflow/label_command_test.go @@ -368,21 +368,24 @@ Triggered by the deploy label on issues only. } // TestLabelCommandNoClashWithExistingLabelTrigger verifies that label_command can coexist -// with a regular label trigger without creating a duplicate issues: YAML block. +// with an existing label-only issues trigger without creating a duplicate issues: YAML block. +// The existing issues block types are merged into the label_command-generated issues block. func TestLabelCommandNoClashWithExistingLabelTrigger(t *testing.T) { tempDir := t.TempDir() - // Workflow that has both a regular label trigger (schedule via default) and label_command + // Workflow that has both an explicit "issues: types: [labeled]" block AND label_command. + // This is the exact key-clash scenario: without merging, two "issues:" keys would appear + // in the compiled YAML, which is invalid and silently broken. workflowContent := `--- name: No Clash Test on: label_command: deploy - schedule: - - cron: "0 * * * *" + issues: + types: [labeled] engine: copilot --- -Both label-command and scheduled trigger. +Both label-command and existing issues labeled trigger. ` workflowPath := filepath.Join(tempDir, "no-clash-test.md") @@ -391,7 +394,7 @@ Both label-command and scheduled trigger. compiler := NewCompiler() err = compiler.CompileWorkflow(workflowPath) - require.NoError(t, err, "CompileWorkflow() should not error when mixing label_command and other triggers") + require.NoError(t, err, "CompileWorkflow() should not error when mixing label_command with existing label trigger") lockFilePath := stringutil.MarkdownToLockFile(workflowPath) lockContent, err := os.ReadFile(lockFilePath) @@ -406,3 +409,31 @@ Both label-command and scheduled trigger. "there should be exactly one 'issues:' trigger block in the compiled YAML, got %d. Compiled:\n%s", issuesCount, lockStr) } + +// TestLabelCommandConflictWithNonLabelTrigger verifies that using label_command alongside +// an issues/pull_request trigger with non-label types returns a validation error. +func TestLabelCommandConflictWithNonLabelTrigger(t *testing.T) { + tempDir := t.TempDir() + + // Workflow with label_command and issues: types: [opened] — non-label type conflicts + workflowContent := `--- +name: Conflict Test +on: + label_command: deploy + issues: + types: [opened] +engine: copilot +--- + +This should fail validation. +` + + workflowPath := filepath.Join(tempDir, "conflict-test.md") + err := os.WriteFile(workflowPath, []byte(workflowContent), 0644) + require.NoError(t, err, "failed to write test workflow") + + compiler := NewCompiler() + err = compiler.CompileWorkflow(workflowPath) + require.Error(t, err, "CompileWorkflow() should error when label_command is combined with non-label issues trigger") + assert.Contains(t, err.Error(), "label_command", "error should mention label_command") +} diff --git a/pkg/workflow/tools.go b/pkg/workflow/tools.go index e1a106870a..eef5ff7293 100644 --- a/pkg/workflow/tools.go +++ b/pkg/workflow/tools.go @@ -180,31 +180,51 @@ func (c *Compiler) applyDefaults(data *WorkflowData, markdownPath string) error }, }, } + // Signal that this workflow has a dispatch item_number input so that + // applyWorkflowDispatchFallbacks and concurrency key building add the + // necessary inputs.item_number fallbacks for manual workflow_dispatch runs. + data.HasDispatchItemNumber = true // Merge other events (if any) — this handles the no-clash requirement: // if the user also has e.g. "issues: {types: [labeled], names: [bug]}" as a - // regular label trigger alongside label_command, merge them rather than - // generating a duplicate "issues:" block. + // regular label trigger alongside label_command, merge the "types" arrays + // rather than generating a duplicate "issues:" block or silently dropping config. if len(data.LabelCommandOtherEvents) > 0 { for eventKey, eventVal := range data.LabelCommandOtherEvents { - if _, exists := labelEventsMap[eventKey]; exists { - // Event already present from label_command generation — keep ours - // (the condition handles filtering by label name at job level) - continue + if existing, exists := labelEventsMap[eventKey]; exists { + // Merge types arrays from user config into the label_command-generated entry. + existingMap, _ := existing.(map[string]any) + userMap, _ := eventVal.(map[string]any) + if existingMap != nil && userMap != nil { + existingTypes, _ := existingMap["types"].([]any) + userTypes, _ := userMap["types"].([]any) + merged := make([]any, 0, len(existingTypes)+len(userTypes)) + merged = append(merged, existingTypes...) + merged = append(merged, userTypes...) + existingMap["types"] = merged + // Other fields (names, branches, etc.) from the user config are preserved. + for k, v := range userMap { + if k != "types" { + existingMap[k] = v + } + } + } + } else { + labelEventsMap[eventKey] = eventVal } - labelEventsMap[eventKey] = eventVal } } // Convert merged events to YAML mergedEventsYAML, err := yaml.Marshal(map[string]any{"on": labelEventsMap}) - if err == nil { - yamlStr := strings.TrimSuffix(string(mergedEventsYAML), "\n") - yamlStr = parser.QuoteCronExpressions(yamlStr) - // Pass frontmatter so label names in "names:" fields get commented out - yamlStr = c.commentOutProcessedFieldsInOnSection(yamlStr, map[string]any{}) - data.On = yamlStr + if err != nil { + return fmt.Errorf("failed to marshal label-command events: %w", err) } + yamlStr := strings.TrimSuffix(string(mergedEventsYAML), "\n") + yamlStr = parser.QuoteCronExpressions(yamlStr) + // Pass frontmatter so label names in "names:" fields get commented out + yamlStr = c.commentOutProcessedFieldsInOnSection(yamlStr, map[string]any{}) + data.On = yamlStr // Build the label-command condition hasOtherEvents := len(data.LabelCommandOtherEvents) > 0 From 38cc6abeda5a5661b0826cb5c57b7991cbb2872d Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 16 Mar 2026 00:07:50 +0000 Subject: [PATCH 10/11] fix: scope label_command permissions to enabled events, use app token when configured - Only add issues:write when issues/pull_request events are in label_command events - Only add discussions:write when discussion is in label_command events - When github-app is configured, skip GITHUB_TOKEN elevation and use app token for the remove_trigger_label step instead (with event-scoped app token scopes) - Include label_command in app token minting trigger condition - Compute FilterLabelCommandEvents once and reuse in both permission blocks Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> --- .github/workflows/smoke-copilot.lock.yml | 1 + pkg/workflow/compiler_activation_job.go | 50 ++++++++++++++++++------ 2 files changed, 40 insertions(+), 11 deletions(-) diff --git a/.github/workflows/smoke-copilot.lock.yml b/.github/workflows/smoke-copilot.lock.yml index bdd884a2ed..4bec7e995d 100644 --- a/.github/workflows/smoke-copilot.lock.yml +++ b/.github/workflows/smoke-copilot.lock.yml @@ -163,6 +163,7 @@ jobs: env: GH_AW_LABEL_NAMES: '["smoke"]' with: + github-token: ${{ secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} script: | const { setupGlobals } = require('/opt/gh-aw/actions/setup_globals.cjs'); setupGlobals(core, github, context, exec, io); diff --git a/pkg/workflow/compiler_activation_job.go b/pkg/workflow/compiler_activation_job.go index 885947e497..a2b0a77f9f 100644 --- a/pkg/workflow/compiler_activation_job.go +++ b/pkg/workflow/compiler_activation_job.go @@ -115,16 +115,31 @@ func (c *Compiler) buildActivationJob(data *WorkflowData, preActivationJobCreate checkoutSteps := c.generateCheckoutGitHubFolderForActivation(data) steps = append(steps, checkoutSteps...) - // Mint a single activation app token upfront if a GitHub App is configured and either - // the reaction or status-comment step will need it. This avoids minting multiple tokens. + // Mint a single activation app token upfront if a GitHub App is configured and any + // step in the activation job will need it (reaction, status-comment, or label removal). + // This avoids minting multiple tokens. hasReaction := data.AIReaction != "" && data.AIReaction != "none" hasStatusComment := data.StatusComment != nil && *data.StatusComment - if data.ActivationGitHubApp != nil && (hasReaction || hasStatusComment) { - // Build the combined permissions needed for reactions and/or status comments + hasLabelCommand := len(data.LabelCommand) > 0 + // Compute filtered label events once and reuse below (permissions + app token scopes) + filteredLabelEvents := FilterLabelCommandEvents(data.LabelCommandEvents) + if data.ActivationGitHubApp != nil && (hasReaction || hasStatusComment || hasLabelCommand) { + // Build the combined permissions needed for all activation steps. + // For label removal we only add the scopes required by the enabled events. appPerms := NewPermissions() - appPerms.Set(PermissionIssues, PermissionWrite) - appPerms.Set(PermissionPullRequests, PermissionWrite) - appPerms.Set(PermissionDiscussions, PermissionWrite) + if hasReaction || hasStatusComment { + appPerms.Set(PermissionIssues, PermissionWrite) + appPerms.Set(PermissionPullRequests, PermissionWrite) + appPerms.Set(PermissionDiscussions, PermissionWrite) + } + if hasLabelCommand { + if sliceutil.Contains(filteredLabelEvents, "issues") || sliceutil.Contains(filteredLabelEvents, "pull_request") { + appPerms.Set(PermissionIssues, PermissionWrite) + } + if sliceutil.Contains(filteredLabelEvents, "discussion") { + appPerms.Set(PermissionDiscussions, PermissionWrite) + } + } steps = append(steps, c.buildActivationAppTokenMintStep(data.ActivationGitHubApp, appPerms)...) } @@ -302,6 +317,11 @@ func (c *Compiler) buildActivationJob(data *WorkflowData, preActivationJobCreate } steps = append(steps, fmt.Sprintf(" GH_AW_LABEL_NAMES: '%s'\n", string(labelNamesJSON))) steps = append(steps, " with:\n") + // Use GitHub App or custom token if configured (avoids needing elevated GITHUB_TOKEN permissions) + labelToken := c.resolveActivationToken(data) + if labelToken != "${{ secrets.GITHUB_TOKEN }}" { + steps = append(steps, fmt.Sprintf(" github-token: %s\n", labelToken)) + } steps = append(steps, " script: |\n") steps = append(steps, generateGitHubScriptWithRequire("remove_trigger_label.cjs")) @@ -452,10 +472,18 @@ func (c *Compiler) buildActivationJob(data *WorkflowData, preActivationJobCreate } // Add write permissions for label removal when label_command is configured. - // Labels on issues/PRs require issues:write; discussion labels require discussions:write. - if len(data.LabelCommand) > 0 { - permsMap[PermissionIssues] = PermissionWrite - permsMap[PermissionDiscussions] = PermissionWrite + // Only grant the scopes required by the enabled events: + // - issues/pull_request events need issues:write (PR labels use the issues REST API) + // - discussion events need discussions:write + // When a github-app token is configured, the GITHUB_TOKEN permissions are irrelevant + // for the label removal step (it uses the app token instead), so we skip them. + if hasLabelCommand && data.ActivationGitHubApp == nil { + if sliceutil.Contains(filteredLabelEvents, "issues") || sliceutil.Contains(filteredLabelEvents, "pull_request") { + permsMap[PermissionIssues] = PermissionWrite + } + if sliceutil.Contains(filteredLabelEvents, "discussion") { + permsMap[PermissionDiscussions] = PermissionWrite + } } perms := NewPermissionsFromMap(permsMap) From eb5c44342ed085517a13ab45c803928cc8d286ed Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Mon, 16 Mar 2026 03:32:15 +0000 Subject: [PATCH 11/11] Add changeset [skip-ci] --- .changeset/patch-add-label-command-trigger.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/patch-add-label-command-trigger.md diff --git a/.changeset/patch-add-label-command-trigger.md b/.changeset/patch-add-label-command-trigger.md new file mode 100644 index 0000000000..1d59bc3869 --- /dev/null +++ b/.changeset/patch-add-label-command-trigger.md @@ -0,0 +1,5 @@ +--- +"gh-aw": patch +--- + +Add support for the `label_command` trigger so workflows can run when a configured label is added to an issue, pull request, or discussion. The activation job now removes the triggering label at startup and exposes `needs.activation.outputs.label_command` for downstream use.