Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions pkg/workflow/compiler.go
Original file line number Diff line number Diff line change
Expand Up @@ -224,6 +224,24 @@ func (c *Compiler) validateWorkflowData(workflowData *WorkflowData, markdownPath
}
}

// Warn when the user has specified custom workflow-level concurrency with cancel-in-progress: true
// AND the workflow has the bot self-cancel risk combination (issue_comment triggers + GitHub App
// safe-outputs). In this case the auto-generated bot-actor isolation cannot be applied because the
// user's concurrency expression is preserved as-is. The user must add the bot-actor isolation
// themselves (e.g. prepend `contains(github.actor, '[bot]') && github.run_id ||` to their group key).
if workflowData.Concurrency != "" &&
strings.Contains(workflowData.Concurrency, "cancel-in-progress: true") &&
hasBotSelfCancelRisk(workflowData) {
fmt.Fprintln(os.Stderr, formatCompilerMessage(markdownPath, "warning",
"Custom workflow-level concurrency with cancel-in-progress: true may cause self-cancellation.\n"+
"safe-outputs.github-app can post comments that re-trigger this workflow via issue_comment,\n"+
"and those passive bot-authored runs can collide with the primary run's concurrency group.\n"+
"Add `contains(github.actor, '[bot]') && github.run_id ||` at the start of your concurrency\n"+
"group expression to route bot-triggered runs to a unique key and prevent self-cancellation.\n"+
"See: https://gh.io/gh-aw/reference/concurrency for details."))
c.IncrementWarningCount()
}
Comment on lines +227 to +243
Copy link

Copilot AI Mar 25, 2026

Choose a reason for hiding this comment

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

This new warning path isn’t covered by tests. There are existing warning-count tests in this package; adding a unit test that asserts (1) warning is emitted/incremented for a risky workflow with custom concurrency + cancel-in-progress: true, and (2) no warning when bot isolation is already present in the custom group, would help prevent regressions.

This issue also appears on line 232 of the same file.

Copilot uses AI. Check for mistakes.

// Emit warning for sandbox.agent: false (disables agent sandbox firewall)
if isAgentSandboxDisabled(workflowData) {
fmt.Fprintln(os.Stderr, console.FormatWarningMessage("⚠️ WARNING: Agent sandbox disabled (sandbox.agent: false). This removes firewall protection. The AI agent will have direct network access without firewall filtering. The MCP gateway remains enabled. Only use this for testing or in controlled environments where you trust the AI agent completely."))
Expand Down
78 changes: 71 additions & 7 deletions pkg/workflow/concurrency.go
Original file line number Diff line number Diff line change
Expand Up @@ -205,6 +205,48 @@ func entityConcurrencyKey(primaryParts []string, tailParts []string, hasItemNumb
return "${{ " + strings.Join(parts, " || ") + " }}"
}

// botIsolatedConcurrencyKey builds a ${{ ... }} concurrency-group expression that
// routes GitHub App bot-triggered runs to a unique github.run_id key, preventing
// passive bot-authored comment events from colliding with the primary run's group.
// When contains(github.actor, '[bot]') is true, the expression short-circuits to
// github.run_id so that bot-triggered runs never share a group with human runs.
func botIsolatedConcurrencyKey(primaryParts []string, tailParts []string, hasItemNumber bool) string {
parts := make([]string, 0, len(primaryParts)+len(tailParts)+2)
// Prepend the bot-actor isolation check: bot runs always get a unique key
parts = append(parts, "contains(github.actor, '[bot]') && github.run_id")
parts = append(parts, primaryParts...)
if hasItemNumber {
parts = append(parts, "inputs.item_number")
}
parts = append(parts, tailParts...)
return "${{ " + strings.Join(parts, " || ") + " }}"
}

// hasIssueCommentTrigger returns true when the workflow has any trigger that uses
// issue_comment events: explicit issue_comment, slash_command, or command triggers.
// These are the only triggers where a GitHub App-authored comment on an issue can
// re-enter the same workflow and match the primary run's workflow-level concurrency
// group, creating the self-cancellation risk described in the issue.
func hasIssueCommentTrigger(workflowData *WorkflowData) bool {
on := workflowData.On
return strings.Contains(on, "issue_comment") ||
strings.Contains(on, "slash_command") ||
len(workflowData.Command) > 0
}

// hasBotSelfCancelRisk returns true when the workflow's auto-generated concurrency
// configuration can be collided with by a passive GitHub App bot-authored event.
// The dangerous combination is: issue_comment triggers + GitHub App safe-outputs.
// When this combination is present, App-authored comments posted by safe-outputs can
// re-trigger the workflow and resolve to the same workflow-level concurrency group
// as the originating run, causing cancel-in-progress to cancel the original run.
func hasBotSelfCancelRisk(workflowData *WorkflowData) bool {
if workflowData.SafeOutputs == nil || workflowData.SafeOutputs.GitHubApp == nil {
return false
}
return hasIssueCommentTrigger(workflowData)
}

// buildConcurrencyGroupKeys builds an array of keys for the concurrency group
func buildConcurrencyGroupKeys(workflowData *WorkflowData, isCommandTrigger bool) []string {
keys := []string{"gh-aw", "${{ github.workflow }}"}
Expand All @@ -214,16 +256,40 @@ func buildConcurrencyGroupKeys(workflowData *WorkflowData, isCommandTrigger bool
// use distinct groups and don't cancel each other.
hasItemNumber := workflowData.HasDispatchItemNumber

// Detect whether the workflow is at risk of bot-self-cancellation.
// When safe-outputs uses a GitHub App token AND the workflow has issue_comment triggers,
// App-authored comments can re-trigger the workflow and collide with the primary run's
// concurrency group. When this risk is present we use botIsolatedConcurrencyKey so that
// bot-triggered runs always resolve to a unique github.run_id key instead.
botRisk := hasBotSelfCancelRisk(workflowData)
if botRisk {
concurrencyLog.Print("Bot self-cancel risk detected: applying bot-actor isolation to concurrency group key")
}

// entityKey selects the correct concurrency key builder based on bot risk.
// It captures both botRisk and hasItemNumber from the outer scope, so call
// sites only need to supply the entity-specific parts.
entityKey := func(primaryParts []string, tailParts []string) string {
if botRisk {
return botIsolatedConcurrencyKey(primaryParts, tailParts, hasItemNumber)
}
return entityConcurrencyKey(primaryParts, tailParts, hasItemNumber)
}

if isCommandTrigger || isSlashCommandWorkflow(workflowData.On) {
// For command/slash_command workflows: use issue/PR number; fall back to run_id when
// neither is available (e.g. manual workflow_dispatch of the outer workflow).
keys = append(keys, "${{ github.event.issue.number || github.event.pull_request.number || github.run_id }}")
// When bot risk is detected, prepend the bot-actor isolation check.
if botRisk {
keys = append(keys, "${{ contains(github.actor, '[bot]') && github.run_id || github.event.issue.number || github.event.pull_request.number || github.run_id }}")
} else {
keys = append(keys, "${{ github.event.issue.number || github.event.pull_request.number || github.run_id }}")
}
Comment on lines +282 to +287
Copy link

Copilot AI Mar 25, 2026

Choose a reason for hiding this comment

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

buildConcurrencyGroupKeys hardcodes a bot-isolated expression for the command/slash_command branch instead of reusing the new botIsolatedConcurrencyKey/entityKey helper. This duplicates the bot-isolation logic and makes future edits easy to miss or diverge. Consider using the shared helper to build the expression for this branch as well (passing the issue/PR primary parts and github.run_id tail).

Suggested change
// When bot risk is detected, prepend the bot-actor isolation check.
if botRisk {
keys = append(keys, "${{ contains(github.actor, '[bot]') && github.run_id || github.event.issue.number || github.event.pull_request.number || github.run_id }}")
} else {
keys = append(keys, "${{ github.event.issue.number || github.event.pull_request.number || github.run_id }}")
}
// Bot-actor isolation is applied via entityKey when botRisk is detected.
keys = append(keys, entityKey(
[]string{"github.event.issue.number", "github.event.pull_request.number"},
[]string{"github.run_id"},
))

Copilot uses AI. Check for mistakes.
} else if isPullRequestWorkflow(workflowData.On) && isIssueWorkflow(workflowData.On) {
// Mixed workflows with both issue and PR triggers
keys = append(keys, entityConcurrencyKey(
keys = append(keys, entityKey(
[]string{"github.event.issue.number", "github.event.pull_request.number"},
[]string{"github.run_id"},
hasItemNumber,
))
} else if isPullRequestWorkflow(workflowData.On) && isDiscussionWorkflow(workflowData.On) {
// Mixed workflows with PR and discussion triggers
Expand All @@ -234,10 +300,9 @@ func buildConcurrencyGroupKeys(workflowData *WorkflowData, isCommandTrigger bool
))
} else if isIssueWorkflow(workflowData.On) && isDiscussionWorkflow(workflowData.On) {
// Mixed workflows with issue and discussion triggers
keys = append(keys, entityConcurrencyKey(
keys = append(keys, entityKey(
[]string{"github.event.issue.number", "github.event.discussion.number"},
[]string{"github.run_id"},
hasItemNumber,
))
} else if isPullRequestWorkflow(workflowData.On) {
// PR workflows: use PR number, fall back to ref then run_id
Expand All @@ -249,10 +314,9 @@ func buildConcurrencyGroupKeys(workflowData *WorkflowData, isCommandTrigger bool
} else if isIssueWorkflow(workflowData.On) {
// Issue workflows: run_id is the fallback when no issue context is available
// (e.g. when a mixed-trigger workflow is started via workflow_dispatch).
keys = append(keys, entityConcurrencyKey(
keys = append(keys, entityKey(
[]string{"github.event.issue.number"},
[]string{"github.run_id"},
hasItemNumber,
))
} else if isDiscussionWorkflow(workflowData.On) {
// Discussion workflows: run_id is the fallback when no discussion context is available.
Expand Down
Loading
Loading