Skip to content

Commit 2e6d7d2

Browse files
atkinsonmclaude
andcommitted
feat: add neutral job conclusion via allow-failure property
Add TaskResult.Neutral (value 6) and ActionResult.Neutral (value 4) to allow jobs to report a neutral/warning conclusion to the Checks API. When a job fails and allow-failure is set (via message property or ACTIONS_ALLOW_FAILURE env var), the runner reports Neutral instead of Failed. The server maps this to the Checks API "neutral" conclusion, which renders as a grey dash icon rather than a red X. Changes: - TaskResult enum: add Neutral = 6 - ActionResult enum: add Neutral = 4 - TaskResultUtil: add Neutral mapping in ToActionResult() - AgentJobRequestMessage: add AllowFailure bool property - JobRunner: override Failed → Neutral in both CompleteJobAsync overloads - Tests: cover merge behavior, return code translation, and allow-failure logic for both message property and env var fallback Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 5ed0c52 commit 2e6d7d2

7 files changed

Lines changed: 170 additions & 1 deletion

File tree

src/Runner.Common/ActionResult.cs

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,8 @@ public enum ActionResult
88

99
Cancelled = 2,
1010

11-
Skipped = 3
11+
Skipped = 3,
12+
13+
Neutral = 4
1214
}
1315
}

src/Runner.Common/Util/TaskResultUtil.cs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,8 @@ public static ActionResult ToActionResult(this TaskResult result)
7171
return ActionResult.Cancelled;
7272
case TaskResult.Skipped:
7373
return ActionResult.Skipped;
74+
case TaskResult.Neutral:
75+
return ActionResult.Neutral;
7476
default:
7577
throw new NotSupportedException(result.ToString());
7678
}

src/Runner.Worker/JobRunner.cs

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -339,6 +339,13 @@ private async Task<TaskResult> CompleteJobAsync(IRunServer runServer, IExecution
339339
jobContext.Debug($"Finishing: {message.JobDisplayName}");
340340
TaskResult result = jobContext.Complete(taskResult);
341341

342+
if (result == TaskResult.Failed && ShouldApplyAllowFailure(jobContext, message))
343+
{
344+
jobContext.Debug("Job failed but allow-failure is set. Reporting as Neutral.");
345+
result = TaskResult.Neutral;
346+
jobContext.Result = result;
347+
}
348+
342349
var jobQueueTelemetry = await ShutdownQueue(throwOnFailure: false);
343350
// include any job telemetry from the background upload process.
344351
if (jobQueueTelemetry?.Count > 0)
@@ -413,6 +420,13 @@ private async Task<TaskResult> CompleteJobAsync(IJobServer jobServer, IExecution
413420
jobContext.Debug($"Finishing: {message.JobDisplayName}");
414421
TaskResult result = jobContext.Complete(taskResult);
415422

423+
if (result == TaskResult.Failed && ShouldApplyAllowFailure(jobContext, message))
424+
{
425+
jobContext.Debug("Job failed but allow-failure is set. Reporting as Neutral.");
426+
result = TaskResult.Neutral;
427+
jobContext.Result = result;
428+
}
429+
416430
if (_runnerSettings.DisableUpdate == true)
417431
{
418432
await WarningOutdatedRunnerAsync(jobContext, message, result);
@@ -495,6 +509,23 @@ private async Task<TaskResult> CompleteJobAsync(IJobServer jobServer, IExecution
495509
throw new AggregateException(exceptions);
496510
}
497511

512+
private bool ShouldApplyAllowFailure(IExecutionContext jobContext, Pipelines.AgentJobRequestMessage message)
513+
{
514+
if (message.AllowFailure)
515+
{
516+
return true;
517+
}
518+
519+
var allowFailureEnv = Environment.GetEnvironmentVariable("ACTIONS_ALLOW_FAILURE");
520+
if (string.Equals(allowFailureEnv, "true", StringComparison.OrdinalIgnoreCase))
521+
{
522+
jobContext.Debug("allow-failure detected via ACTIONS_ALLOW_FAILURE environment variable.");
523+
return true;
524+
}
525+
526+
return false;
527+
}
528+
498529
private static void AddDebuggerConnectionTelemetry(IExecutionContext jobContext, string result)
499530
{
500531
jobContext.Global.JobTelemetry.Add(new JobTelemetry

src/Sdk/DTPipelines/Pipelines/AgentJobRequestMessage.cs

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -260,6 +260,13 @@ public bool EnableDebugger
260260
set;
261261
}
262262

263+
[DataMember(EmitDefaultValue = false)]
264+
public bool AllowFailure
265+
{
266+
get;
267+
set;
268+
}
269+
263270
[DataMember(EmitDefaultValue = false)]
264271
public DebuggerTunnelInfo DebuggerTunnel
265272
{

src/Sdk/DTWebApi/WebApi/TaskResult.cs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,5 +22,8 @@ public enum TaskResult
2222

2323
[EnumMember]
2424
Abandoned = 5,
25+
26+
[EnumMember]
27+
Neutral = 6,
2528
}
2629
}

src/Test/L0/Util/TaskResultUtilL0.cs

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
using GitHub.DistributedTask.WebApi;
2+
using GitHub.Runner.Common;
23
using GitHub.Runner.Common.Util;
34
using Xunit;
45

@@ -200,6 +201,50 @@ public void TaskResultsMerge()
200201
merged = TaskResultUtil.MergeTaskResults(TaskResult.Skipped, TaskResult.Failed);
201202
// Actual
202203
Assert.Equal(TaskResult.Skipped, merged);
204+
205+
//
206+
// Neutral is terminal (not overwritten by subsequent results)
207+
//
208+
// Act.
209+
merged = TaskResultUtil.MergeTaskResults(TaskResult.Neutral, TaskResult.Succeeded);
210+
// Actual
211+
Assert.Equal(TaskResult.Neutral, merged);
212+
// Act.
213+
merged = TaskResultUtil.MergeTaskResults(TaskResult.Neutral, TaskResult.Failed);
214+
// Actual
215+
Assert.Equal(TaskResult.Neutral, merged);
216+
// Act.
217+
merged = TaskResultUtil.MergeTaskResults(null, TaskResult.Neutral);
218+
// Actual
219+
Assert.Equal(TaskResult.Neutral, merged);
220+
// Act.
221+
merged = TaskResultUtil.MergeTaskResults(TaskResult.Succeeded, TaskResult.Neutral);
222+
// Actual
223+
Assert.Equal(TaskResult.Neutral, merged);
224+
}
225+
}
226+
227+
[Fact]
228+
[Trait("Level", "L0")]
229+
[Trait("Category", "Common")]
230+
public void TaskResultNeutralReturnCodeTranslate()
231+
{
232+
using (TestHostContext hc = new(this))
233+
{
234+
TaskResult neutral = TaskResultUtil.TranslateFromReturnCode(TaskResultUtil.TranslateToReturnCode(TaskResult.Neutral));
235+
Assert.Equal(TaskResult.Neutral, neutral);
236+
}
237+
}
238+
239+
[Fact]
240+
[Trait("Level", "L0")]
241+
[Trait("Category", "Common")]
242+
public void TaskResultNeutralToActionResult()
243+
{
244+
using (TestHostContext hc = new(this))
245+
{
246+
ActionResult actionResult = TaskResult.Neutral.ToActionResult();
247+
Assert.Equal(ActionResult.Neutral, actionResult);
203248
}
204249
}
205250
}

src/Test/L0/Worker/JobRunnerL0.cs

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -175,5 +175,84 @@ public async Task WorksWithRunnerJobRequestMessageType()
175175
Assert.Equal(TaskResult.Succeeded, _jobEc.Result);
176176
}
177177
}
178+
179+
[Fact]
180+
[Trait("Level", "L0")]
181+
[Trait("Category", "Worker")]
182+
public async Task AllowFailureConvertsFailedToNeutral()
183+
{
184+
using (TestHostContext hc = CreateTestContext())
185+
{
186+
_jobExtension.Setup(x => x.InitializeJob(It.IsAny<IExecutionContext>(), It.IsAny<Pipelines.AgentJobRequestMessage>()))
187+
.Throws(new Exception());
188+
189+
var message = GetMessage(JobRequestMessageTypes.RunnerJobRequest);
190+
message.AllowFailure = true;
191+
192+
await _jobRunner.RunAsync(message, _tokenSource.Token);
193+
194+
Assert.Equal(TaskResult.Neutral, _jobEc.Result);
195+
}
196+
}
197+
198+
[Fact]
199+
[Trait("Level", "L0")]
200+
[Trait("Category", "Worker")]
201+
public async Task AllowFailureDoesNotAffectSucceeded()
202+
{
203+
using (TestHostContext hc = CreateTestContext())
204+
{
205+
var message = GetMessage(JobRequestMessageTypes.RunnerJobRequest);
206+
message.AllowFailure = true;
207+
208+
await _jobRunner.RunAsync(message, _tokenSource.Token);
209+
210+
Assert.Equal(TaskResult.Succeeded, _jobEc.Result);
211+
}
212+
}
213+
214+
[Fact]
215+
[Trait("Level", "L0")]
216+
[Trait("Category", "Worker")]
217+
public async Task AllowFailureEnvVarConvertsFailedToNeutral()
218+
{
219+
using (TestHostContext hc = CreateTestContext())
220+
{
221+
_jobExtension.Setup(x => x.InitializeJob(It.IsAny<IExecutionContext>(), It.IsAny<Pipelines.AgentJobRequestMessage>()))
222+
.Throws(new Exception());
223+
224+
var message = GetMessage(JobRequestMessageTypes.RunnerJobRequest);
225+
226+
Environment.SetEnvironmentVariable("ACTIONS_ALLOW_FAILURE", "true");
227+
try
228+
{
229+
await _jobRunner.RunAsync(message, _tokenSource.Token);
230+
Assert.Equal(TaskResult.Neutral, _jobEc.Result);
231+
}
232+
finally
233+
{
234+
Environment.SetEnvironmentVariable("ACTIONS_ALLOW_FAILURE", null);
235+
}
236+
}
237+
}
238+
239+
[Fact]
240+
[Trait("Level", "L0")]
241+
[Trait("Category", "Worker")]
242+
public async Task AllowFailureWithPipelineAgentJobRequest()
243+
{
244+
using (TestHostContext hc = CreateTestContext())
245+
{
246+
_jobExtension.Setup(x => x.InitializeJob(It.IsAny<IExecutionContext>(), It.IsAny<Pipelines.AgentJobRequestMessage>()))
247+
.Throws(new Exception());
248+
249+
var message = GetMessage(JobRequestMessageTypes.PipelineAgentJobRequest);
250+
message.AllowFailure = true;
251+
252+
await _jobRunner.RunAsync(message, _tokenSource.Token);
253+
254+
Assert.Equal(TaskResult.Neutral, _jobEc.Result);
255+
}
256+
}
178257
}
179258
}

0 commit comments

Comments
 (0)