Skip to content

Commit 11cfc58

Browse files
Fix safe-output jobs running when workflow is cancelled
Issue: Safe-output jobs (create_issue, add_comment, etc.) were running even when the workflow was cancelled, causing failures due to missing artifacts. Root Cause: The condition 'if: (!cancelled())' evaluates to true when dependency jobs are skipped (which happens during workflow cancellation). GitHub Actions treats 'skipped' differently from 'cancelled'. Solution: Add check for 'needs.agent.result != "skipped"' to prevent safe-output jobs from running when the agent job was skipped due to workflow cancellation. This ensures: - ✅ Runs when agent succeeds - ✅ Runs when agent fails (for error reporting) - ❌ Does NOT run when agent is skipped (workflow cancelled) Related: #2890
1 parent cd115b7 commit 11cfc58

File tree

3 files changed

+38
-22
lines changed

3 files changed

+38
-22
lines changed

pkg/workflow/expressions.go

Lines changed: 18 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -336,26 +336,39 @@ func BuildNotFromFork() *ComparisonNode {
336336
}
337337

338338
func BuildSafeOutputType(outputType string, min int) ConditionNode {
339-
// Use !cancelled() instead of always() to respect workflow cancellation
339+
// Use !cancelled() && needs.agent.result != 'skipped' to properly handle workflow cancellation
340340
// !cancelled() allows jobs to run when dependencies fail (for error reporting)
341-
// but skips them when the workflow is cancelled (desired behavior)
341+
// needs.agent.result != 'skipped' prevents running when workflow is cancelled (dependencies get skipped)
342342
notCancelledFunc := &NotNode{
343343
Child: BuildFunctionCall("cancelled"),
344344
}
345345

346-
// If min > 0, only return !cancelled() without the contains check
346+
// Check that agent job was not skipped (happens when workflow is cancelled)
347+
agentNotSkipped := &ComparisonNode{
348+
Left: BuildPropertyAccess(fmt.Sprintf("needs.%s.result", constants.AgentJobName)),
349+
Operator: "!=",
350+
Right: BuildStringLiteral("skipped"),
351+
}
352+
353+
// Combine !cancelled() with agent not skipped check
354+
baseCondition := &AndNode{
355+
Left: notCancelledFunc,
356+
Right: agentNotSkipped,
357+
}
358+
359+
// If min > 0, only return base condition without the contains check
347360
// This is needed to ensure the job runs even with 0 outputs to enforce the minimum constraint
348361
// Wrap in parentheses to ensure proper YAML interpretation
349362
if min > 0 {
350-
return &ParenthesesNode{Child: notCancelledFunc}
363+
return &ParenthesesNode{Child: baseCondition}
351364
}
352365

353366
containsFunc := BuildFunctionCall("contains",
354367
BuildPropertyAccess(fmt.Sprintf("needs.%s.outputs.output_types", constants.AgentJobName)),
355368
BuildStringLiteral(outputType),
356369
)
357370
return &AndNode{
358-
Left: notCancelledFunc,
371+
Left: baseCondition,
359372
Right: containsFunc,
360373
}
361374
}

pkg/workflow/expressions_cancelled_test.go

Lines changed: 11 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -5,17 +5,17 @@ import (
55
"testing"
66
)
77

8-
// TestBuildSafeOutputTypeWithCancelled verifies that BuildSafeOutputType uses !cancelled()
9-
// instead of always() to properly respect workflow cancellation.
8+
// TestBuildSafeOutputTypeWithCancelled verifies that BuildSafeOutputType properly handles workflow cancellation.
109
//
1110
// Background:
1211
// - always() runs even when the workflow is cancelled (incorrect behavior)
13-
// - !cancelled() runs unless the workflow is cancelled (correct behavior)
12+
// - !cancelled() alone is insufficient (returns true when dependencies are skipped during cancellation)
13+
// - !cancelled() && needs.agent.result != 'skipped' is correct (prevents running when workflow is cancelled)
1414
//
1515
// This test ensures safe-output jobs:
1616
// 1. Run when dependencies succeed
1717
// 2. Run when dependencies fail (for error reporting)
18-
// 3. Skip when the workflow is cancelled (respecting user intent)
18+
// 3. Skip when the workflow is cancelled (dependencies get skipped, not cancelled)
1919
func TestBuildSafeOutputTypeWithCancelled(t *testing.T) {
2020
tests := []struct {
2121
name string
@@ -25,35 +25,38 @@ func TestBuildSafeOutputTypeWithCancelled(t *testing.T) {
2525
unexpectedContains []string
2626
}{
2727
{
28-
name: "min=0 should use !cancelled() with contains check",
28+
name: "min=0 should use !cancelled() and agent not skipped with contains check",
2929
outputType: "create_issue",
3030
min: 0,
3131
expectedContains: []string{
3232
"!cancelled()",
33+
"needs.agent.result != 'skipped'",
3334
"contains(needs.agent.outputs.output_types, 'create_issue')",
3435
},
3536
unexpectedContains: []string{
3637
"always()",
3738
},
3839
},
3940
{
40-
name: "min>0 should use (!cancelled()) without contains check",
41+
name: "min>0 should use !cancelled() and agent not skipped without contains check",
4142
outputType: "create_issue",
4243
min: 1,
4344
expectedContains: []string{
44-
"(!cancelled())",
45+
"!cancelled()",
46+
"needs.agent.result != 'skipped'",
4547
},
4648
unexpectedContains: []string{
4749
"always()",
4850
"contains(needs.agent.outputs.output_types, 'create_issue')",
4951
},
5052
},
5153
{
52-
name: "push-to-pull-request-branch should use !cancelled()",
54+
name: "push-to-pull-request-branch should use !cancelled() and agent not skipped",
5355
outputType: "push_to_pull_request_branch",
5456
min: 0,
5557
expectedContains: []string{
5658
"!cancelled()",
59+
"needs.agent.result != 'skipped'",
5760
"contains(needs.agent.outputs.output_types, 'push_to_pull_request_branch')",
5861
},
5962
unexpectedContains: []string{

pkg/workflow/safe_outputs_min_condition_test.go

Lines changed: 9 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,7 @@ func TestSafeOutputConditionWithMin(t *testing.T) {
3838
},
3939
},
4040
},
41-
expectedCondition: "(!cancelled())",
41+
expectedCondition: "needs.agent.result != 'skipped'",
4242
unexpectedCondition: "contains(needs.agent.outputs.output_types, 'missing_tool')",
4343
},
4444
{
@@ -67,7 +67,7 @@ func TestSafeOutputConditionWithMin(t *testing.T) {
6767
"missing-tool": false,
6868
},
6969
},
70-
expectedCondition: "(!cancelled())",
70+
expectedCondition: "needs.agent.result != 'skipped'",
7171
unexpectedCondition: "contains(needs.agent.outputs.output_types, 'create_issue')",
7272
},
7373
{
@@ -81,7 +81,7 @@ func TestSafeOutputConditionWithMin(t *testing.T) {
8181
"missing-tool": false,
8282
},
8383
},
84-
expectedCondition: "(!cancelled())",
84+
expectedCondition: "needs.agent.result != 'skipped'",
8585
unexpectedCondition: "contains(needs.agent.outputs.output_types, 'add-comment')",
8686
},
8787
}
@@ -148,10 +148,10 @@ func TestBuildSafeOutputTypeWithMin(t *testing.T) {
148148
unexpectedCondition: "",
149149
},
150150
{
151-
name: "with min>0 should only have (!cancelled())",
151+
name: "with min>0 should only have (!cancelled()) and agent not skipped",
152152
outputType: "create-issue",
153153
min: 1,
154-
expectedCondition: "(!cancelled())",
154+
expectedCondition: "needs.agent.result != 'skipped'",
155155
unexpectedCondition: "contains(needs.agent.outputs.output_types, 'create-issue')",
156156
},
157157
{
@@ -165,7 +165,7 @@ func TestBuildSafeOutputTypeWithMin(t *testing.T) {
165165
name: "missing-tool with min>0",
166166
outputType: "missing-tool",
167167
min: 2,
168-
expectedCondition: "(!cancelled())",
168+
expectedCondition: "needs.agent.result != 'skipped'",
169169
unexpectedCondition: "contains(needs.agent.outputs.output_types, 'missing-tool')",
170170
},
171171
}
@@ -219,9 +219,9 @@ func TestMinConditionInCompiledWorkflow(t *testing.T) {
219219
t.Fatalf("Failed to build job: %v", err)
220220
}
221221

222-
// Verify that the condition only contains (!cancelled()) and not the contains check
223-
if !strings.Contains(job.If, "(!cancelled())") {
224-
t.Error("Expected condition to contain '(!cancelled())'")
222+
// Verify that the condition contains agent not skipped check
223+
if !strings.Contains(job.If, "needs.agent.result != 'skipped'") {
224+
t.Error("Expected condition to contain 'needs.agent.result != 'skipped''")
225225
}
226226
if strings.Contains(job.If, "contains(needs.agent.outputs.output_types, 'missing-tool')") {
227227
t.Error("Expected condition NOT to contain contains check when min > 0")

0 commit comments

Comments
 (0)