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.